` t
The shared CLI configuration supplies one session root under the Harness home, the picker gains a workspace scope, and the handoff carries the target directory.
-**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row resolves `sessions` beneath `DSH_HOME`, with the standard `~/.dsh` fallback when the environment has not been initialized. TUI, Web, and headless therefore consume one default without launcher patches or a boot slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice.
+**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row calls the app-boot-provided `dshHomePath('sessions')`, which uses the canonical `DSH_HOME` resolver and its standard `~/.dsh` fallback. TUI, Web, and headless therefore consume one default without a session-specific launcher patch or slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice.
**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for.
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
index d31dc5f047..9e5e126f4b 100644
--- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
@@ -18,7 +18,7 @@ Status: implemented
共享 CLI é…�ç½®æ��ä¾› Harness home 下的å�Œä¸€ä¸ªä¼šè¯�æ ¹ç›®å½•ï¼Œé€‰æ‹©å™¨èŽ·å¾— workspace 范围,交接过程æ�ºå¸¦ç›®æ ‡ç›®å½•。
-**å˜å‚¨ã€‚** 共享 base 在 `apps/cli/config/base.cordis.yml` 䏿‹¥æœ‰é»˜è®¤å€¼ï¼šå…¶ `session-persistence-jsonl` é…�置项把 `sessions` è§£æž�到 `DSH_HOME` 下;环境尚未åˆ�å§‹åŒ–æ—¶åˆ™ä½¿ç”¨æ ‡å‡†çš„ `~/.dsh` å›žé€€å€¼ã€‚å› æ¤ TUIã€�Web 与 headless 使用å�Œä¸€ä¸ªé»˜è®¤å€¼ï¼Œæ— 需å�¯åŠ¨å™¨è¡¥ä¸�或å�¯åŠ¨æ§½ä½�。若 overlay 或个人 patch 显å¼�å£°æ˜Žæ ¹ç›®å½•ï¼Œå®ƒä¼šæ•´ä½“æ›¿æ�¢è¯¥é…�置项的 `config`,并继ç»ä½œä¸ºéƒ¨ç½²çš„æ�ƒå¨�选择。
+**å˜å‚¨ã€‚** 共享 base 在 `apps/cli/config/base.cordis.yml` 䏿‹¥æœ‰é»˜è®¤å€¼ï¼šå…¶ `session-persistence-jsonl` é…�置项调用由 app-boot æ��供的 `dshHomePath('sessions')`,该函数使用规范的 `DSH_HOME` è§£æž�器å�Šå…¶æ ‡å‡†çš„ `~/.dsh` å›žé€€å€¼ã€‚å› æ¤ TUIã€�Web 与 headless 使用å�Œä¸€ä¸ªé»˜è®¤å€¼ï¼Œæ— 需针对会è¯�çš„å�¯åŠ¨å™¨è¡¥ä¸�或槽ä½�。若 overlay 或个人 patch 显å¼�å£°æ˜Žæ ¹ç›®å½•ï¼Œå®ƒä¼šæ•´ä½“æ›¿æ�¢è¯¥é…�置项的 `config`,并继ç»ä½œä¸ºéƒ¨ç½²çš„æ�ƒå¨�选择。
**是范围,ä¸�是排除。** 当å‰� workspace 之外的 workspace 是一ç§�展示范围,而ä¸�是ç¦�用ç�†ç”±ã€‚`showResume()` 汇总æ¯�一æ�¡è®°å½•,`ResumePicker` æŒ�有一个 `'workspace' | 'all'` çš„ `scope`,默认为当å‰� workspaceï¼Œå› æ¤å¸¸è§�åœºæ™¯æ¯«æ— å�˜åŒ–。Tab 切æ�¢èŒƒå›´ï¼›èŒƒå›´è¡Œä¼šè¯´æ˜Žå½“å‰�生效的范围,以å�Šå�¦ä¸€ä¸ªèŒƒå›´ä¸‹çš„æ•°é‡�;在全 workspace èŒƒå›´ä¸æ¯�一行都报告自己的 workspaceï¼Œè€Œè¯¥æ ‡ç¾å�ªåœ¨å±•示它的范围里æ‰�åŠ å…¥å�¯æ�œç´¢æ–‡æœ¬ã€‚切æ�¢èŒƒå›´ä¼šæ¸…空查询和选ä¸é¡¹ï¼Œä½¿é«˜äº®è¡Œå§‹ç»ˆå±žäºŽå�¯è§�列表;而é€�行的 workspace 行会让该范围下的æ¯�一行在终端里多å� 一行,å�¯è§�æ�¡æ•°é¢„ç®—å·²ç»�把这一点计入。
diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml
index a16f07a63c..5fc6eacf97 100644
--- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-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/feature/2026-07-29-persistent-bash-str-replace-editor.md
-2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35
-2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f
+2026-07-29-persistent-bash-str-replace-editor.md: 22851078c1cc8fa9d5716afa41c8a2e2b7e7725c
+2026-07-29-persistent-bash-str-replace-editor.zh.md: cf4d18f26d11380a637d573e8ea98cb3ccfcdb59
diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
index a97af750bd..22851078c1 100644
--- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
+++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
@@ -18,6 +18,8 @@ Some deployments need a one-call Bash schema whose shell state survives across m
Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable.
+The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface, disables its other model-facing consumers, and leaves the Web host, browser, Workspace, persistence, sandbox, and permission stack in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox.
+
## Alternatives considered
**One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark.
@@ -30,4 +32,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo
## Consequences
-Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
+Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
index 0c2ab26693..cf4d18f26d 100644
--- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
@@ -18,6 +18,8 @@
两个æ�’件都进入 Python runtime é—包。æŒ�ä¹… Bash çš„é—包还包å�« PTY æœ�务ï¼�本地å�Žç«¯ï¼Œä»¥å�Šè¯¥å�Žç«¯è¦�求的沙箱æœ�务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,æ¯�个打包å�Žçš„ macOS è¿�行时å�¯æ‰§è¡Œæ–‡ä»¶éƒ½ä¼šæ�ºå¸¦ä¸€ä¸ª `-spawn-helper` ä¼´éš�文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` è¡¥ä¸�会先检查 `DSH_NODE_PTY_SPAWN_HELPER`ï¼Œå› æ¤å¯¹å½“å‰�æ��ä¾›é�žä¼´éš� helper 的外部消费方而言,该å�˜é‡�ä»�是真æ£çš„覆盖项。未设置该覆盖时,补ä¸�会在打包å�¯æ‰§è¡Œæ–‡ä»¶çš„ä¼´éš�文件å˜åœ¨æ—¶è§£æž�它,å�¦åˆ™åœ¨æ™®é€š Node è¿�行ä¸ä¿�留上游查找方å¼�。若 helper 缺失或ä¸�å�¯æ‰§è¡Œï¼ŒmacOS 构建器会在å�‘布å‰�失败。
+已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) 覆盖层在常规 Web 界é�¢ä¹‹ä¸Šç»„å�ˆè¿™ä¸¤ä¸ªæ�’件,ç¦�用该界é�¢çš„å…¶ä»–é�¢å�‘模型的消费方,并ä¿�ç•™ Web 宿主ã€�æµ�览器ã€�Workspaceã€�æŒ�久化ã€�沙箱与æ�ƒé™�æ ˆã€‚æœ¬åœ° PTY å�Žç«¯ä¼šåœ¨åˆ›å»º shell æ—¶è§£æž�会è¯�的有效沙箱模å¼�。å�ªè¦�该所有者ä»�有打开的 shell 或ä»�在进行ä¸çš„ spawn,å�¦ä¸€ç§�æ�ƒé™�模å¼�就会在对应的会è¯�事件æ��交å‰�é�到拒ç»�;编辑器则继ç»ç»�ç”± Web 文件系统沙箱è¿�行。
+
## 考虑过的替代方案
**å�•一组å�ˆå…¼å®¹æ�’件。** 被拒ç»�ï¼Œå› ä¸ºä¸¤ä¸ªå·¥å…·äº’ä¸�ä¾�赖,组å�ˆå‘½å��还会把å�¯å¤�用能力绑定到æŸ�个基准。
@@ -30,4 +32,4 @@
## �果
-Profile å�¯ä»¥é€šè¿‡é…�ç½® persona å’Œæ��è¿°å¤�现外部 Agent,而底层包ä¿�æŒ�通用。æŒ�ä¹… Bash 需è¦�拥有它的 Agent 与真实 PTY å�Žç«¯ï¼›shell 退出ã€�超时或å�–消会丢失状æ€�。编辑器把安全与å�˜æ›´ç–ç•¥å§”æ‰˜ç»™æŒ‚è½½çš„æ–‡ä»¶ç³»ç»Ÿæ ˆã€‚è¿�行时 wheel 包的消费方ä»�æ— éœ€å®‰è£… Nodeï¼›Linux wheel 包包å�«ä¸€ä¸ªå�¯æ‰§è¡Œæ–‡ä»¶ï¼ŒmacOS wheel 包还包å�«å…¶ç§�有原生 helper。
+Profile å�¯ä»¥é€šè¿‡é…�ç½® persona å’Œæ��è¿°å¤�现外部 Agent,而底层包ä¿�æŒ�通用。æŒ�ä¹… Bash 需è¦�拥有它的 Agent 与真实 PTY å�Žç«¯ï¼›shell 退出ã€�超时或å�–消会丢失状æ€�。编辑器把安全与å�˜æ›´ç–ç•¥å§”æ‰˜ç»™æŒ‚è½½çš„æ–‡ä»¶ç³»ç»Ÿæ ˆã€‚Core Web profile ä¿�ç•™ Web æ�ƒé™�ï¼Œä½†å¿…é¡»å…ˆå…³é—æŒ�ä¹… shell æ‰�能更改æ�ƒé™�模å¼�。è¿�行时 wheel 包的消费方ä»�æ— éœ€å®‰è£… Nodeï¼›Linux wheel 包包å�«ä¸€ä¸ªå�¯æ‰§è¡Œæ–‡ä»¶ï¼ŒmacOS wheel 包还包å�«å…¶ç§�有原生 helper。
diff --git a/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.i18n.yaml
new file mode 100644
index 0000000000..9e267e82a0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.md
+2026-07-30-current-sandbox-policy-context.md: 093f9a8a0b58256bb4f2301cc62fa83629de4fbd
+2026-07-30-current-sandbox-policy-context.zh.md: 729ea9d50c7dbbfa027e1cad11929ae1c2b379b1
diff --git a/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.md b/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.md
new file mode 100644
index 0000000000..093f9a8a0b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.md
@@ -0,0 +1,63 @@
+# Agent Note: Current sandbox policy context
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-current-sandbox-policy-context.zh.md)
+
+## Problem
+
+The sandbox policy already enforced and logged each session's file-effect mode, but a fresh model request did not contain that state. In a Web session under `read-only`, write and edit schemas remained visible, so the model claimed it could write and learned otherwise only after a denied call. After `/permission danger-full-access`, the next request carried the approval-policy change but still omitted the sandbox mode. Denial results were therefore the first model-visible policy source even when the user asked about capability before any operation.
+
+## Decision
+
+`dsh-sandbox-policy`, the owner of mode and workspace-root resolution, registers one `sandbox:policy` cache-safe context contribution. Every agent request resolves the active session directly through `ctx.sandboxPolicy.resolve({ session })`; there is no denial-history scan or process-local “last told� state.
+
+The policy contribution is capability-neutral and present for every agent session. It does not maintain a second inventory of mounted backends or tools; model-visible schemas remain the authority for available operations, while the context conditions its claims on any available operation that the DSH file sandbox enforces. The [capability-neutral policy context decision](../simplification/2026-07-31-capability-neutral-sandbox-policy-context.md) supersedes the earlier family-registration mechanism while retaining this note's cache-safe delivery and durable snapshot design.
+
+The contribution states only facts shared by every enforcement dialect. `read-only` says an available sandbox-enforced operation cannot modify files in the standing mode and directs the model to try an available tool normally, then follow any denial and escalation guidance that tool returns. `workspace-write` states the canonical session workspace with non-exclusive wording and summarizes, without enumerating, that some platform temporary areas may also be writable. `danger-full-access` says the DSH file sandbox does not restrict file modifications by available operations. Backend-selected temporary paths, `/dev/null`, runner readiness, exact tool availability, and other policy domains are absent because `resolve()` cannot establish them at request assembly.
+
+The existing `dsh-system-prompt` assembly now has ordered dynamic contexts alongside stable system sections and tool schemas. After assembling one step, agent-loop renders all active contexts as one full snapshot with an explicit supersession statement. It appends a sourced `user/message` only when no retained snapshot exists, the bytes changed, compaction removed the retained message, or the final contribution disappeared and needs one clearing snapshot. The snapshot is appended after existing history and before `step/start`, so a changed policy preserves the preceding system-and-conversation cache prefix. The session event itself reconstructs the exact model input; `request/header` remains byte-identical when only policy context changes.
+
+Ownership stays narrow. Approval policy contributes its complete current `ask` or `never` fact to the same full snapshot; migrating sandbox alone would not preserve cache because `/permission` changes both owners. Plan mode remains `plan:policy`, and tool plugins continue to own schemas plus attempt, denial, and escalation guidance. Context states standing policy; filesystem, one-shot bash, and terminal backends remain the enforcement boundaries.
+
+The cache decision follows current source rather than analogy alone. Codex models permissions as a developer-role `WorldState` section with a persisted fingerprint, emits it only when state changes or retained history lost the fragment, and records the snapshot transition. Hermes keeps its system prompt fixed for a session and explicitly prepends changing skill, model, and voice notices to the next user message to avoid invalidating prompt cache. Pi has no comparable built-in sandbox state, and Claude Code's current native implementation is not publicly inspectable; Anthropic's public cache guidance nevertheless places changing per-request context after the stable cached prefix.
+
+The earlier real-provider Web fixture quantified the defect in the system-section version. The first `danger-full-access` and `workspace-write` requests each reported only 256 cache-read tokens against 14,691 and 14,782 uncached input tokens. Later steps under an unchanged policy reported approximately 14.7k–15.5k cache-read tokens. Moving only the sandbox sentence would not fix those misses because the same preset switch also rewrote the approval-policy system section.
+
+## Wording evidence
+
+The wording experiment pre-registered preemptive refusal as its primary endpoint and required the old standing sentence to produce at least one refusal in twelve fresh sessions before any replacement could be judged. On 2026-07-30, commit `2bf41990401b194bd8637f07bbd90c67a9eeac75` ran `deepseek-v4-flash` through the shipped Web composition with the exact positive-control sentence `Bash commands run under the "read-only" file sandbox.` and the current tool-owned attempt guidance. The control produced zero preemptive refusals and zero speculative escalations; all twelve sessions made an ordinary bash call, observed a denial, escalated in the same turn, received approval, and landed the requested file. No sample was excluded.
+
+After the cache-safe delivery change, commit `10d4e0ff7b68d38fc4403403b644aac442b97a00` repeated the same twelve-session positive control through the new tail-context channel. It again produced zero preemptive refusals and zero speculative escalations; all twelve sessions made an ordinary first call, observed denial, escalated in the same turn, and received approval. Eight landed the exact requested file, and no sample was excluded.
+
+Both positive controls therefore failed the pre-registered sensitivity gate. The formal twelve-session Candidate A and B arms were not run, and these experiments do not select or validate the current wording. They establish that the earlier five-of-twelve result is not reproducible under this task and current tool guidance, and that a stronger positive control or different task distribution is required before making model-behavior rate claims. Deterministic tests below establish truthful request construction and replay only.
+
+The cache-safe delivery rework then supplied a separate, non-statistical acceptance comparison over the neutral Web task `Create the relative path policy-neutral.txt ...`; it does not replace the pre-registered twelve-session experiment. Candidate A's categorical read-only statement produced a text refusal with zero tool calls. Candidate B added one composition-conditioned sentence only for enforced families whose tools expose escalation. A fresh real-provider run then issued an ordinary `write`, observed the read-only denial, retried the same operation in the same turn with `sandbox_permissions: "workspace-write"`, received approval, read the file back, and verified the exact contents. It made no speculative escalation. Across the permission switches and four mutation steps, cache reads were 14,848–15,872 tokens while uncached input was 59–306 tokens per request, directly demonstrating the stable-prefix benefit.
+
+## Alternatives considered
+
+**Narrate only mode changes.** Rejected because it leaves a fresh session uninformed and makes the first denied operation the policy-discovery mechanism. It also requires a baseline definition that is unnecessary when current state can be rendered directly.
+
+**Scan denial history or remember the last narrated mode.** Rejected because denial events describe attempted operations, not authoritative current state, while process-local bookkeeping does not survive resume. The owner can fold the durable policy directly on every request.
+
+**Put current policy in a dynamic system section.** Rejected after real provider evidence showed that a first-time permission switch reduced cache reads to 256 tokens while roughly 14.7k input tokens missed. DeepSeek matches complete prefixes; changing the first wire message prevents reuse of the longer system-plus-history prefix.
+
+**Call `agent.inject()` independently from each policy owner.** Rejected because sibling listener order would define model order, separate messages could expose mismatched intermediate snapshots, and every owner would need its own compaction-retention scan. The existing assembly owner can order contributions and materialize one atomic full snapshot.
+
+**A generic runtime-facts package.** Rejected because the existing system-prompt assembly already owns sections, schemas, variables, scope, and the authoritative per-step waterfall. Extending that owner with ordered contexts adds no package or second registry service.
+
+**Repeat tool schemas or plan guidance in the context.** Rejected because those surfaces already have owners and independent lifecycles. Approval current state joins the snapshot only because the same `/permission` switch changes it and leaving its system section would retain the cache defect.
+
+**Keep Candidate A after the cache-safe move.** Rejected by the neutral real-provider task: the model returned a pure text refusal and made no tool call despite the existing bash attempt guidance. The surviving anti-refusal principle states no escalation mechanics itself; it tells the model not to infer impossibility from the standing label, then delegates denial and escalation behavior back to the available tool.
+
+**Keep sandbox mode absent because a standing mode label once caused preemptive refusal.** Rejected because a fresh Web request otherwise exposes mutation tools while withholding their standing policy, producing false capability claims before the first operation. The earlier live measurement remains a required counter-test: five of twelve turns ended without a tool call under `Bash commands run under the "read-only" file sandbox.` The committed tool-owned attempt guidance postdates that measurement, so the replacement is selected through a new positive-control experiment under the current tool contract rather than assuming the old and current conditions match.
+
+**A separate model-context package.** Rejected because the policy owner can resolve current session state directly and the existing assembly service can order it. A new package would add a shallow composition seam and documentation/gate surface around the same request boundary.
+
+**Enumerate writable temporary roots.** Rejected because the backend is selected later at `confine()`: bwrap, Landlock, Seatbelt, and the in-process filesystem fence do not grant one common temporary-path set. Host-specific paths in a standing request would be both unstable and overclaimed.
+
+## Consequences
+
+A model receives the standing file policy before probing a tool, and the next request after `/permission` reflects the committed mode. The stable system prompt no longer changes for sandbox or approval state; a changed full context snapshot is append-only after retained history, and unchanged state adds no message. Older snapshots remain in history but are explicitly superseded by the latest full snapshot. The statement is guidance, not an enforcement guard: runtime safety still comes from filesystem, one-shot bash, and terminal backends consuming the same resolved policy.
+
+Focused tests pin all modes, canonical roots, switch timing, service disposal, context ordering, clearing, stable request headers, resume, and byte stability across different `TMPDIR` values. Keyless assembled snapshots pin the durable context message through real Loader compositions. Keyless replay owns the neutral denial-to-escalation trajectory; it is a structural regression proof, not wording-selection evidence.
diff --git a/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.zh.md b/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.zh.md
new file mode 100644
index 0000000000..729ea9d50c
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.zh.md
@@ -0,0 +1,63 @@
+# Agent Note: 当å‰�沙箱ç–略上下文
+
+Status: implemented
+
+[English](2026-07-30-current-sandbox-policy-context.md) | 䏿–‡
+
+## 问题
+
+沙箱ç–略已ç»�强制执行并记录æ¯�个会è¯�的文件æ“�作模å¼�,但新的模型请求并ä¸�包å�«è¿™ä¸€çжæ€�。在 `read-only` 下的 Web 会è¯�ä¸ï¼Œwrite 与 edit schema ä»�ç„¶å�¯è§�ï¼Œå› æ¤æ¨¡åž‹ä¼šå£°ç§°è‡ªå·±èƒ½å¤Ÿå†™å…¥ï¼Œç›´åˆ°ä¸€æ¬¡è¢«æ‹’ç»�的调用å�Žæ‰�å�‘现事实并é�žå¦‚æ¤ã€‚执行 `/permission danger-full-access` å�Žï¼Œä¸‹ä¸€ä¸ªè¯·æ±‚带有批准ç–ç•¥å�˜æ›´ï¼Œå�´ä»�çœ�略沙箱模å¼�ã€‚å› æ¤ï¼Œå�³ä½¿ç”¨æˆ·åœ¨ä»»ä½•æ“�作å‰�询问能力,拒ç»�结果也是模型å�¯è§�的首个ç–ç•¥æ�¥æº�。
+
+## 决ç–
+
+`dsh-sandbox-policy` 负责解æž�模å¼�ä¸Žå·¥ä½œåŒºæ ¹ç›®å½•ï¼Œå¹¶æ³¨å†Œä¸€é¡¹ç¼“å˜å®‰å…¨çš„ `sandbox:policy` 上下文贡献。æ¯�次 agent(智能体)请求都通过 `ctx.sandboxPolicy.resolve({ session })` 直接解æž�当å‰�会è¯�ï¼›ä¸�å˜åœ¨æ‹’ç»�历å�²æ‰«æ��或进程本地的「上次告知ã€�状æ€�。
+
+该ç–略贡献ä¸�ä¾�赖具体能力,并å˜åœ¨äºŽæ¯�个 agent 会è¯�ä¸ã€‚它ä¸�会å�¦è¡Œç»´æŠ¤ä¸€ä»½å·²æŒ‚è½½å�Žç«¯æˆ–工具清å�•;模型å�¯è§�çš„ schema ä»�是å�¯ç”¨æ“�作的æ�ƒå¨�æ�¥æº�,而上下文会将其声明é™�定在 DSH 文件沙箱所强制执行的任何å�¯ç”¨æ“�作上。[ä¸�ä¾�赖具体能力的ç–略上下文决ç–](../simplification/2026-07-31-capability-neutral-sandbox-policy-context.md)å�–代了较早的家æ—�注册机制,å�Œæ—¶ä¿�留本 Agent Note 的缓å˜å®‰å…¨äº¤ä»˜ä¸ŽæŒ�久快照设计。
+
+该贡献å�ªè¯´æ˜Žæ‰€æœ‰å¼ºåˆ¶æ‰§è¡Œæ–¹è¨€æ‰€å…±æœ‰çš„事实。`read-only` 表明å�—沙箱强制执行的å�¯ç”¨æ“�作在常驻模å¼�ä¸‹æ— æ³•ä¿®æ”¹æ–‡ä»¶ï¼Œå¹¶æŒ‡ç¤ºæ¨¡åž‹æ£å¸¸å°�试å�¯ç”¨å·¥å…·ï¼Œéš�å�Žé�µå¾ªè¯¥å·¥å…·è¿”回的任何拒ç»�与å�‡æ�ƒå¼•导。`workspace-write` 用é�žæŽ’他措辞说明规范化的会è¯�工作区,并概述æŸ�些平å�°ä¸´æ—¶åŒºåŸŸå�¯èƒ½ä¹Ÿå�¯å†™ï¼Œè€Œä¸�é€�一列举。`danger-full-access` 表明 DSH 文件沙箱ä¸�会é™�制å�¯ç”¨æ“�作修改文件。å�Žç«¯é€‰æ‹©çš„临时路径ã€�`/dev/null`ã€�runner 就绪状æ€�ã€�确切的工具å�¯ç”¨æƒ…况和其他ç–略领域都ä¸�ä¼šå‡ºçŽ°ï¼Œå› ä¸º `resolve()` æ— æ³•åœ¨è¯·æ±‚ç»„è£…æ—¶ç¡®å®šå®ƒä»¬ã€‚
+
+现有 `dsh-system-prompt` 组装在稳定系统段与工具 schema 之外,还包å�«æœ‰åº�的动æ€�上下文。组装一个æ¥éª¤å�Žï¼Œagent loop(智能体循环)会将所有活动上下文渲染æˆ�一份带显å¼�å�–代声明的完整快照。仅当ä¸�å˜åœ¨ä¿�留快照ã€�å—节å�‘生å�˜åŒ–ã€�压缩(compaction)移除了ä¿�留消æ�¯ï¼Œæˆ–最å�Žä¸€é¡¹è´¡çŒ®æ¶ˆå¤±è€Œéœ€è¦�一份清除快照时,它æ‰�ä¼šè¿½åŠ ä¸€æ�¡å¸¦æ�¥æº�çš„ `user/message`ã€‚å¿«ç…§è¿½åŠ åœ¨çŽ°æœ‰åŽ†å�²ä¹‹å�Žã€�`step/start` 之å‰�ï¼Œå› æ¤ç–ç•¥å�˜åŒ–æ—¶ä»�会ä¿�ç•™æ¤å‰�的系统与对è¯�缓å˜å‰�缀。会è¯�事件本身å�¯ä»¥é‡�建确切的模型输入;å�ªæœ‰ç–略上下文å�˜åŒ–时,`request/header` ä»�é€�å—节相å�Œã€‚
+
+归属范围ä¿�æŒ�收敛。批准ç–略会将其完整的当å‰� `ask` 或 `never` 事实贡献给å�Œä¸€ä»½å®Œæ•´å¿«ç…§ï¼›å�ªè¿�ç§»æ²™ç®±æ— æ³•ä¿�留缓å˜ï¼Œå› 为 `/permission` 会å�Œæ—¶æ”¹å�˜ä¸¤æ–¹ã€‚计划模å¼�ä»�ç”± `plan:policy` 负责,工具æ�’件也继ç»è´Ÿè´£å�„自的 schema,以å�Šå°�试ã€�æ‹’ç»�与å�‡çº§å¼•导。上下文负责说明常驻ç–略;文件系统ã€�一次性 bash 与终端å�Žç«¯ä»�是强制执行边界。
+
+缓å˜å†³ç–ä¾�æ�®å½“å‰�æº�ç �,而ä¸�å�ªä¾�é� 类比。Codex å°†æ�ƒé™�建模为 developer 角色的 `WorldState` 段并ä¿�å˜å…¶æŒ‡çº¹ï¼›å�ªæœ‰çжæ€�å�˜åŒ–或ä¿�留的历å�²ä¸¢å¤±è¯¥ç‰‡æ®µæ—¶æ‰�å�‘出它,å�Œæ—¶è®°å½•快照转æ�¢ã€‚Hermes 在会è¯�期间ä¿�æŒ�系统æ��示è¯�ä¸�å�˜ï¼Œå¹¶æ˜Žç¡®å°†ä¸�æ–å�˜åŒ–çš„ skill(技能)ã€�模型与è¯éŸ³é€šçŸ¥å‰�置到下一æ�¡ç”¨æˆ·æ¶ˆæ�¯ï¼Œä»¥å…�æ��示è¯�缓å˜å¤±æ•ˆã€‚Pi 没有å�¯æ¯”的内置沙箱状æ€�,Claude Code 当å‰�çš„åŽŸç”Ÿå®žçŽ°ä¹Ÿæ— æ³•å…¬å¼€æ£€è§†ï¼›ä¸�过,Anthropic çš„å…¬å¼€ç¼“å˜æŒ‡å�—ä»�å°†ä¸�æ–å�˜åŒ–çš„é€�请求上下文放在稳定缓å˜å‰�缀之å�Žã€‚
+
+å…ˆå‰�接入真实æ��供方的 Web fixture(测试å‰�置数æ�®ï¼‰é‡�化了系统段版本的缺陷。首次 `danger-full-access` å’Œ `workspace-write` 请求分别å�ªæœ‰ 256 个缓å˜è¯»å�– token,而未缓å˜è¾“å…¥ token 为 14,691 å’Œ 14,782 个。相å�Œç–略下的å�Žç»æ¥éª¤æŠ¥å‘Šçº¦ 14.7k–15.5k 个缓å˜è¯»å�– token。å�ªç§»åŠ¨æ²™ç®±è¯å�¥æ— 法修å¤�这些未命ä¸ï¼Œå› 为å�Œä¸€æ¬¡ preset 切æ�¢è¿˜ä¼šæ”¹å†™æ‰¹å‡†ç–略系统段。
+
+## 措辞��
+
+措辞实验预先登记「预防性拒ç»�ã€�为主è¦�终点,并è¦�求旧常驻å�¥å�在å��二个 fresh session ä¸è‡³å°‘产生一次拒ç»�,之å�Žæ‰�能评判任何替代措辞。2026-07-30,commit `2bf41990401b194bd8637f07bbd90c67a9eeac75` 通过已交付的 Web 组å�ˆè¿�行 `deepseek-v4-flash`,使用精确的阳性对照å�¥å� `Bash commands run under the "read-only" file sandbox.` 与当å‰�工具归属方的å°�试引导。对照组产生零次预防性拒ç»�和零次推测性å�‡çº§ï¼›å��二个会è¯�全部先å�‘起普通 bash 调用ã€�观察到拒ç»�ã€�在å�Œä¸€è½®æ¬¡å�‡çº§ã€�获得批准,并让所请求文件实际è�½ç›˜ã€‚æ²¡æœ‰æ ·æœ¬è¢«æŽ’é™¤ã€‚
+
+缓å˜å®‰å…¨äº¤ä»˜å�˜æ›´å�Žï¼Œcommit `10d4e0ff7b68d38fc4403403b644aac442b97a00` 通过新的尾部上下文通é�“é‡�å¤�了å�Œä¸€é¡¹å��二会è¯�阳性对照。结果å†�次为零次预防性拒ç»�和零次推测性å�‡æ�ƒï¼›å��二个会è¯�的首次调用å�‡ä¸ºæ™®é€šè°ƒç”¨ï¼Œéš�å�Žè§‚察到拒ç»�ã€�在å�Œä¸€è½®æ¬¡å�‡æ�ƒå¹¶èŽ·å¾—æ‰¹å‡†ã€‚å…¶ä¸å…«ä¸ªä¼šè¯�让所请求文件按确切è¦�求è�½ç›˜ï¼Œæ²¡æœ‰æ ·æœ¬è¢«æŽ’除。
+
+å› æ¤ï¼Œä¸¤é¡¹é˜³æ€§å¯¹ç…§å�‡æœªé€šè¿‡é¢„先登记的ç�µæ•�度门槛。Candidate A 与 B çš„æ£å¼�å��二会è¯�实验组å�‡æœªè¿�行,这些实验ä¸�选择也ä¸�验è¯�当å‰�措辞。它们说明先å‰�å��二次ä¸äº”æ¬¡çš„ç»“æžœæ— æ³•åœ¨æœ¬ä»»åŠ¡ä¸Žå½“å‰�工具引导下å¤�现;在声明模型行为率之å‰�,需è¦�更强的阳性对照或ä¸�å�Œçš„任务分布。下述确定性测试å�ªè¯�æ˜Žè¯·æ±‚æž„é€ ä¸Žå›žæ”¾çœŸå®žä¸€è‡´ã€‚
+
+éš�å�Žï¼Œç¼“å˜å®‰å…¨äº¤ä»˜é‡�å�šé’ˆå¯¹ä¸æ€§ Web 任务 `Create the relative path policy-neutral.txt ...` æ��供了一次独立的é�žç»Ÿè®¡éªŒæ”¶å¯¹æ¯”;它ä¸�å�–代预先登记的å��二会è¯�实验。Candidate A çš„ç»�对化å�ªè¯»å£°æ˜Žå¯¼è‡´æ¨¡åž‹ä»¥çº¯æ–‡æœ¬æ‹’ç»�,工具调用为零。Candidate B å�ªé’ˆå¯¹å�—强制执行ã€�且其工具公开å�‡æ�ƒèƒ½åŠ›çš„å®¶æ—�å¢žåŠ ä¸€å�¥æŒ‰ç»„å�ˆæ�¡ä»¶åŒ–的文案。éš�å�Žä¸€æ¬¡å…¨æ–°çš„真实æ��供方è¿�行先å�‘出普通 `write`,观察到å�ªè¯»æ‹’ç»�,å†�在å�Œä¸€è½®æ¬¡ç”¨ `sandbox_permissions: "workspace-write"` é‡�试å�Œä¸€æ“�作,获得批准ã€�è¯»å›žæ–‡ä»¶å¹¶æ ¸éªŒç¡®åˆ‡å†…å®¹ã€‚å®ƒæ²¡æœ‰è¿›è¡ŒæŽ¨æµ‹æ€§å�‡æ�ƒã€‚在æ�ƒé™�切æ�¢å’Œå››ä¸ªå�˜æ›´æ¥éª¤ä¸ï¼Œæ¯�个请求的缓å˜è¯»å�–为 14,848–15,872 个 token,未缓å˜è¾“入为 59–306 个 token,直接è¯�明了稳定å‰�缀的收益。
+
+## 曾考虑的替代方案
+
+**ä»…å�™è¿°æ¨¡å¼�å�˜æ›´ã€‚** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºè¿™ä¼šè®©æ–°ä¼šè¯�ä¸�了解ç–略,并把首次被拒ç»�çš„æ“�作å�˜æˆ�ç–ç•¥å�‘现机制。如果å�¯ä»¥ç›´æŽ¥æ¸²æŸ“当å‰�状æ€�ï¼Œä¹Ÿå°±æ— éœ€é¢�外定义基线。
+
+**扫æ��æ‹’ç»�历å�²æˆ–è®°ä½�上次å�™è¿°çš„æ¨¡å¼�。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºæ‹’ç»�事件æ��述的是å°�试过的æ“�作,而ä¸�是æ�ƒå¨�的当å‰�状æ€�ï¼›è¿›ç¨‹æœ¬åœ°çš„ç°¿è®°ä¹Ÿæ— æ³•è·¨æ�¢å¤�ä¿�留。归属方å�¯ä»¥åœ¨æ¯�次请求时直接折å� æŒ�ä¹…ç–略。
+
+**把当å‰�ç–略放入动æ€�系统段。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºçœŸå®žæ��供方è¯�æ�®æ˜¾ç¤ºï¼Œé¦–次æ�ƒé™�切æ�¢å�Žç¼“å˜è¯»å�–é™�至 256 个 token,而约 14.7k 个输入 token 未命ä¸ç¼“å˜ã€‚DeepSeek 匹é…�完整å‰�缀;改å�˜ç¬¬ä¸€æ�¡ wire 消æ�¯ä¼šé˜»æ¢å¤�用更长的系统与历å�²å‰�缀。
+
+**ç”±æ¯�个ç–略归属方独立调用 `agent.inject()`。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºå�Œçº§ç›‘å�¬å™¨çš„顺åº�会决定模型所è§�顺åº�,分开的消æ�¯å�¯èƒ½æš´éœ²ä¸�匹é…�çš„ä¸é—´å¿«ç…§ï¼Œå¹¶ä¸”æ¯�个归属方都需è¦�å�„自扫æ��压缩å�Žçš„ä¿�留状æ€�。现有组装归属方å�¯ä»¥å¯¹è´¡çŒ®æŽ’åº�,并具体化一份原å�化的完整快照。
+
+**通用è¿�行时事实包。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºçŽ°æœ‰ç³»ç»Ÿæ��示è¯�组装已ç»�拥有段ã€�schemaã€�å�˜é‡�ã€�作用域和æ�ƒå¨�çš„é€�æ¥éª¤ waterfall(瀑布å¼�äº‹ä»¶ï¼‰ã€‚ä¸ºè¯¥å½’å±žæ–¹å¢žåŠ æœ‰åº�ä¸Šä¸‹æ–‡ï¼Œæ— éœ€æ–°å¢žåŒ…æˆ–ç¬¬äºŒä¸ªæ³¨å†Œè¡¨æœ�务。
+
+**在上下文ä¸é‡�å¤�工具 schema 或计划引导。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºè¿™äº›æŽ¥å�£å·²æœ‰å�„自归属方和独立生命周期。批准的当å‰�状æ€�åŠ å…¥å¿«ç…§ï¼Œä»…ä»…æ˜¯å› ä¸ºå�Œä¸€ä¸ª `/permission` 切æ�¢ä¼šæ”¹å�˜å®ƒï¼Œè€ŒæŠŠå®ƒç•™åœ¨ç³»ç»Ÿæ®µä¼šä¿�留缓å˜ç¼ºé™·ã€‚
+
+**缓å˜å®‰å…¨è¿�ç§»å�Žä»�ä¿�ç•™ Candidate A。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºä¸æ€§çš„真实æ��供方任务ä¸ï¼Œå°½ç®¡å·²æœ‰ bash å°�试引导,模型ä»�以纯文本拒ç»�,且没有调用工具。ä¿�留下æ�¥çš„å��预防性拒ç»�原则本身ä¸�说明任何å�‡æ�ƒæœºåˆ¶ï¼›å®ƒå‘Šè¯‰æ¨¡åž‹ä¸�è¦�ä»Žå¸¸é©»æ ‡ç¾æŽ¨æ–æ“�作ä¸�å�¯èƒ½å®Œæˆ�,然å�ŽæŠŠæ‹’ç»�与å�‡æ�ƒè¡Œä¸ºäº¤è¿˜ç»™å�¯ç”¨å·¥å…·ã€‚
+
+**ç»§ç»çœ�略沙箱模å¼�ï¼Œå› ä¸ºå¸¸é©»æ¨¡å¼�æ ‡ç¾æ›¾å¼•å�‘预防性拒ç»�。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºæ–°çš„ Web 请求å�¦åˆ™ä¼šæš´éœ²å�˜æ›´å·¥å…·ï¼Œå�´éš�去这些工具的常驻ç–略,导致模型在首次æ“�作å‰�错误声称自身能力。先å‰�的线上测é‡�ä»�是必须执行的å��è¯�测试:使用 `Bash commands run under the "read-only" file sandbox.` 时,å��äºŒä¸ªè½®æ¬¡ä¸æœ‰äº”个没有调用工具。已æ��交的工具归属方å°�试引导晚于该测é‡�ï¼Œå› æ¤åº”通过当å‰�工具契约下的新阳性对照实验选择替代文案,而ä¸�能å�‡è®¾æ—§æ�¡ä»¶ä¸Žå½“å‰�æ�¡ä»¶ç›¸å�Œã€‚
+
+**独立的模型上下文包。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºç–略归属方å�¯ä»¥ç›´æŽ¥è§£æž�当å‰�会è¯�状æ€�,现有组装æœ�务也å�¯ä»¥å¯¹å…¶æŽ’åº�。新包å�ªä¼šå›´ç»•å�Œä¸€ä¸ªè¯·æ±‚边界引入浅层组å�ˆ seam å’Œé¢�外的文档ï¼�é—¨ç¦�表é�¢ã€‚
+
+**枚举å�¯å†™ä¸´æ—¶æ ¹ç›®å½•。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºå�Žç«¯è¦�到ç¨�å�Žçš„ `confine()` æ‰�会选定:bwrapã€�Landlockã€�Seatbelt 和进程内文件系统围æ �å¹¶ä¸�授予一套共å�Œçš„临时路径。常驻请求ä¸çš„主机特定路径既ä¸�稳定,也会作出过度承诺。
+
+## �果
+
+模型在试探工具å‰�就会收到常驻文件ç–略,且 `/permission` å�Žçš„下一个请求会å��æ˜ å·²æ��交的模å¼�。稳定的系统æ��示è¯�ä¸�å†�éš�沙箱或批准状æ€�å�˜åŒ–ï¼›å�˜åŒ–å�Žçš„完整上下文快照会在ä¿�留的历å�²ä¹‹å�Žä»…è¿½åŠ ï¼ŒçŠ¶æ€�ä¸�å�˜æ—¶ä¸�å¢žåŠ æ¶ˆæ�¯ã€‚较旧的快照ä»�ä¿�留在历å�²ä¸ï¼Œä½†æœ€æ–°çš„完整快照会明确å�–代它们。该声明是引导,而ä¸�是强制执行护æ �:è¿�行时安全ä»�æ�¥è‡ªæ–‡ä»¶ç³»ç»Ÿã€�一次性 bash 与终端å�Žç«¯æ¶ˆè´¹å�Œä¸€é¡¹è§£æž�完æˆ�çš„ç–略。
+
+è�šç„¦æµ‹è¯•固定了所有模å¼�ã€�è§„èŒƒåŒ–æ ¹ç›®å½•ã€�切æ�¢æ—¶æœºã€�æœ�务释放ã€�上下文顺åº�ã€�清除ã€�稳定的请求 headerã€�æ�¢å¤�,以å�Šä¸�å�Œ `TMPDIR` 值下的å—èŠ‚ç¨³å®šæ€§ã€‚æ— å¯†é’¥çš„ç»„è£…å¿«ç…§é€šè¿‡çœŸå®ž Loader 组å�ˆå›ºå®šæŒ�久上下文消æ�¯ã€‚æ— å¯†é’¥å›žæ”¾è´Ÿè´£å›ºå®šä¸æ€§çš„æ‹’ç»�到å�‡çº§è½¨è¿¹ï¼›å®ƒæ˜¯ç»“构回归è¯�明,而ä¸�是措辞选型è¯�æ�®ã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml
index dde9f82a6d..b5068fb402 100644
--- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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-30-deepseek-onboarding-credential-setup.md
-2026-07-30-deepseek-onboarding-credential-setup.md: 571b81a1a2e6f392f2553070048964d49941aae9
-2026-07-30-deepseek-onboarding-credential-setup.zh.md: 744c30814f84d063f196ce20ba48fb993d0b7713
+2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4
+2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247
diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
index 571b81a1a2..ed53ffe64d 100644
--- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
+++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
@@ -12,11 +12,11 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
-**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract.
+**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
-**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret.
+**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
-**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability suppresses the modal because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later dismisses a missing-credential overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
+**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload.
## Alternatives considered
@@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
## Consequences
-The first-run flow leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
+The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md
index 744c30814f..340728ab93 100644
--- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md
@@ -10,13 +10,13 @@ Status: implemented
## 决ç–
-**Models 与首次使用引导共享å�Œä¸€ä¸ªå°±ç»ªçжæ€�投影。**`ui-models` 维护一个 store,把 `llm.providers({})`ã€�脱æ•�å�Žçš„ `settings.describe({})` 和批é‡�调用的 `credentials.describe({refs})` è�”接为å�Œä¸€ä»½çжæ€�。首次使用投影选å�–ç”± `llm-deepseek` namespace 所有ã€�设置路径为空的 `deepseek-official` å�¯é…�ç½®æ��供方æ�¡ç›®ï¼Œè¯»å�–生效的 `apiKeyEnv`ï¼Œå¹¶æ£€æŸ¥å¯¹åº”çš„å‡æ�®æ��述符。å�Œä¸€æ��供方 ID ä¸‹çš„å˜æ´»è·¯ç”±è‹¥æ²¡æœ‰åŒ¹é…�çš„å�¯é…�ç½®æ��供方声明,首次使用引导会将其视为适é…�器缺失。若 `apiKey` å—é�¢é‡�对应的 secret æ§½ä½�æ ‡è®°ä¸ºå·²è®¾ç½®ï¼Œä¹Ÿä¼šåˆ¤å®šä¸ºå°±ç»ªï¼Œå…¼å®¹é…�ç½®å› æ¤ä¸�会误触å�‘浮层;通过进程环境æ��ä¾›çš„å‡æ�®è‹¥å·²é…�置,å�Œæ ·åˆ¤å®šä¸ºå°±ç»ªå¹¶ä¿�æŒ�å�ªè¯»ã€‚
+**Models 与首次使用引导共享å�Œä¸€ä¸ªå°±ç»ªçжæ€�投影。**`ui-models` 维护一个 store,把 `llm.providers({})`ã€�脱æ•�å�Žçš„ `settings.describe({})` 和批é‡�调用的 `credentials.describe({refs})` è�”接为å�Œä¸€ä»½çжæ€�。首次使用投影选å�–ç”± `llm-deepseek` namespace 与空 settings path æŒ�有的 `deepseek-official` å�¯é…�ç½®æ��供方æ�¡ç›®ï¼Œè¯»å�–生效的 `apiKeyEnv`ï¼Œå¹¶æ£€æŸ¥å¯¹åº”çš„å‡æ�®æ��述符。å�Œ provider id 但没有匹é…�å�¯é…�ç½®æ��ä¾›æ–¹å£°æ˜Žçš„å˜æ´»è·¯ç”±ï¼Œåœ¨é¦–次使用引导ä¸è§†ä¸ºé€‚é…�器缺失。若 `apiKey` å—é�¢é‡�对应的 secret æ§½ä½�æ ‡è®°ä¸ºå·²è®¾ç½®ï¼Œä¹Ÿä¼šåˆ¤å®šä¸ºå°±ç»ªï¼Œå…¼å®¹é…�ç½®å› æ¤ä¸�会误触å�‘页é�¢ï¼›é€šè¿‡è¿›ç¨‹çŽ¯å¢ƒæ��ä¾›çš„å‡æ�®è‹¥å·²é…�置,å�Œæ ·åˆ¤å®šä¸ºå°±ç»ªå¹¶ä¿�æŒ�å�ªè¯»ã€‚
-**设置外壳å�ªè´¡çŒ®å¯¼èˆªçжæ€�,ä¸�æŒ�有æ��供方ç–略。**`ui-settings` å£°æ˜Žä¸€ä¸ªæ ¹ä½œç”¨åŸŸçš„ `settings.onboarding` list slot,并告知注册方当å‰�界é�¢æ˜¯å�¦ä¸ºç©ºç™½ Hero。其ç§�有 `openSection(id)` 回调会打开设置é�¢æ�¿å¹¶åˆ‡æ�¢åˆ°ä¸€ä¸ªå·²æ³¨å†Œåˆ†åŒºã€‚`ui-models` 沿用 Models 分区所使用ã€�感知 slot 声明的延迟注册路径æ�¥æ³¨å†Œ DeepSeek æµ®å±‚ï¼Œå› æ¤æ�’ä»¶åŠ è½½é¡ºåº�ä¸�会æˆ�为契约。
+**设置外壳å�ªè´¡çŒ®æŽ’åº�与导航,ä¸�æŒ�有æ��供方ç–略。** `ui-settings` å£°æ˜Žä¸€ä¸ªæ ¹ä½œç”¨åŸŸçš„ `settings.onboarding` list slot,并在当å‰�界é�¢ä¸ºç©ºç™½ Hero 时,æ¯�次å�ªæŒ‚载一个有åº�æ¥éª¤ã€‚当å‰�注册方会收到 `complete()` å’Œç§�有 `openSection(id)` 回调;完æˆ�当å‰�æ¥éª¤å�Žï¼Œæ‰€æœ‰æ�ƒè½¬äº¤ç»™ä¸‹ä¸€é¡¹ã€‚`ui-models` 沿用 Models 分区所使用ã€�感知 slot 声明的延迟注册路径æ�¥æ³¨å†Œ DeepSeek æ¥éª¤ï¼Œå› æ¤æ�’ä»¶åŠ è½½é¡ºåº�ä¸�会æˆ�为契约,独立贡献的对è¯�æ¡†ä¹Ÿæ— æ³•å †å� 。排在它之å‰�的产å“�级欢迎æ¥éª¤ç”±[版本化欢迎决ç–](2026-07-30-versioned-gui-welcome-onboarding.md)å�•独æŒ�有。
-**浮层å�ªè´Ÿè´£è·³è½¬åˆ°å”¯ä¸€çš„凿�®ç¼–辑器。**适é…�器已挂载且处于活跃状æ€�,其引用å�¯è§£æž�ã€�å�¯å†™ä½†å°šæœªé…�置时,界é�¢ä¼šæ˜¾ç¤ºä¸€ä¸ªæ“�作按钮,用于打开「设置ã€�çš„ Models 分区。该分区已有的 DeepSeek 设置å�¡ç‰‡å…¨æ�ƒè´Ÿè´£å¯†ç �输入框ã€�`credentials.set({ref, value})`ã€�写入失败处ç�†å’Œå†™å…¥å�Žåˆ·æ–°ï¼›é¦–次使用浮层ç»�ä¸�æŒ�有或æ��交 secret。
+**首次使用页é�¢å�ªè´Ÿè´£è·³è½¬åˆ°å”¯ä¸€çš„凿�®ç¼–辑器。**适é…�器已挂载且处于活跃状æ€�,其引用å�¯è§£æž�ã€�å�¯å†™ä½†å°šæœªé…�置时,界é�¢ä¼šæ˜¾ç¤ºä¸€ä¸ªæ“�作按钮,用于打开「设置ã€�çš„ Models 分区。该分区已有的 DeepSeek 设置å�¡ç‰‡å…¨æ�ƒè´Ÿè´£å¯†ç �输入框ã€�`credentials.set({ref, value})`ã€�写入失败处ç�†å’Œå†™å…¥å�Žåˆ·æ–°ï¼›é¦–次使用页é�¢ç»�ä¸�æŒ�有或æ��交 secret。
-**ä¸�å�¯ç”¨çжæ€�ä¸�会拦截产å“�交互。**å�¯é…�ç½®æ��供方æ�¡ç›®ç¼ºå¤±ã€�路由未激活ã€�åˆ�å§‹è�”接失败ã€�部署å�ªè¯»ã€�è®¾ç½®èƒ½åŠ›æ— æ³•è§£æž�æˆ–å‡æ�®èƒ½åŠ›æ— æ³•è§£æž�æ—¶å�‡ä¸�显示模æ€�æ¡†ï¼Œå› ä¸ºé¦–æ¬¡ä½¿ç”¨å¼•å¯¼çš„æ“�ä½œæ— æ³•ä¿®å¤�这些状æ€�。Models 页ä»�是部署诊æ–与é‡�试界é�¢ã€‚「ç¨�å�Žé…�ç½®ã€�å�ªä¼šåœ¨å½“å‰�已挂载界é�¢ä¸å…³é—凿�®ç¼ºå¤±æµ®å±‚,ä¸�写入任何完æˆ�状æ€�。设置ã€�凿�®ã€�æ��供方拓扑和连接失效事件都会刷新共享è�”æŽ¥ï¼Œå› æ¤å¤–éƒ¨å‡æ�®æ›´æ–°æ— 需é‡�æ–°åŠ è½½é¡µé�¢å�³å�¯å…³é—已打开的浮层。
+**ä¸�å�¯ç”¨çжæ€�ä¸�会å� ä½�产å“�。** å�¯é…�ç½®æ��供方æ�¡ç›®ç¼ºå¤±ã€�路由ä¸�活跃ã€�åˆ�å§‹è�”接失败ã€�部署å�ªè¯»æˆ–设置ï¼�凿�®èƒ½åŠ›æ— æ³•è§£æž�时,都会直接完æˆ�而ä¸�渲染该æ¥éª¤ï¼Œå› ä¸ºé¦–æ¬¡ä½¿ç”¨å¼•å¯¼æ— æ³•ä¿®å¤�这些状æ€�。Models 页ä»�是部署诊æ–与é‡�试界é�¢ã€‚「ç¨�å�Žé…�ç½®ã€�å�ªä¼šå®Œæˆ�å��调器当å‰�è¿™ä¸€æ¬¡ç¼ºå°‘å‡æ�®çš„æ¥éª¤ï¼Œä¸�写入任何完æˆ�状æ€�。设置ã€�凿�®ã€�æ��供方拓扑和连接失效事件都会刷新共享è�”æŽ¥ï¼Œå› æ¤å¤–éƒ¨å‡æ�®æ›´æ–°æ— 需é‡�æ–°åŠ è½½é¡µé�¢å�³å�¯å®Œæˆ�已打开的æ¥éª¤ã€‚
## 曾考虑的替代方案
@@ -30,4 +30,4 @@ Status: implemented
## �果
-首次使用æµ�ç¨‹æ— éœ€é‡�å�¯å�³å�¯å¼•导用户å‰�å¾€éš�产å“�æ��供的适é…�å™¨å·²æœ‰çš„ç¼–è¾‘å™¨ï¼šæ— å¯†é’¥æµ�览器测试在隔离的 harness 家目录下å�¯åŠ¨çœŸå®ž Web 组å�ˆï¼Œä¾�照浮层æ“�作å‰�å¾€ Models,通过该页é�¢æŠŠç”Ÿæˆ�的密钥å˜å…¥è¯¥ç›®å½•çš„ `.env`,验è¯�密钥未进入 DOMã€�ARIA 或æµ�览器控制å�°è¾“出,并确认è¿�行ä¸çš„页é�¢æŠ¥å‘Šå·²é…�ç½®ã€‚å®Œæ•´çš„æ— å¯†é’¥ Web 回放链路还固化了å�Œä¸€æ��供方 ID 下的ä¸�å�¯é…�置回放路由ä¸�ä¼šé˜»å¡žæ— å…³æµ�程。纯就绪状æ€�测试与 React 测试固化了å—é�¢é‡�凿�®ã€�æ–‡ä»¶å‡æ�®ã€�è¿›ç¨‹çŽ¯å¢ƒå‡æ�®ã€�æ��供方缺失ã€�能力缺失ã€�导航ã€�å�–消和外部失效行为。该æµ�程直接继承é…�置平é�¢å·²è®°å½•的基础é™�制,ä¸�会å�¦åŠ å±€éƒ¨çš„æœºå¯†å˜å‚¨ã€�脱æ•�或设置替æ�¢å�˜é€šæ–¹æ¡ˆã€‚
+有åº�æµ�程从产å“�å£°æ˜Žé¡µå¼€å§‹ï¼Œæ— éœ€é‡�å�¯å�³å�¯å¼•导用户å‰�å¾€éš�产å“�æ��供的适é…�å™¨å·²æœ‰çš„ç¼–è¾‘å™¨ï¼šæ— å¯†é’¥æµ�览器测试在隔离的 harness 家目录下å�¯åŠ¨çœŸå®ž Web 组å�ˆï¼Œç¡®è®¤å£°æ˜Žå�Žä¾�ç…§ DeepSeek 页é�¢å‰�å¾€ Models,通过该页é�¢æŠŠç”Ÿæˆ�的密钥å˜å…¥è¯¥ç›®å½•çš„ `.env`,验è¯�密钥未进入 DOMã€�ARIA 或æµ�览器控制å�°è¾“出,并确认è¿�行ä¸çš„页é�¢æŠ¥å‘Šå·²é…�ç½®ã€‚å®Œæ•´çš„æ— å¯†é’¥ Web 回放也固定了å�Œ id çš„ä¸�å�¯é…�置回放路由ä¸�ä¼šé˜»å¡žæ— å…³æµ�程。纯就绪状æ€�测试与 React 测试固化了å—é�¢é‡�凿�®ã€�æ–‡ä»¶å‡æ�®ã€�è¿›ç¨‹çŽ¯å¢ƒå‡æ�®ã€�æ��供方缺失ã€�能力缺失ã€�导航ã€�å�–消ã€�外部失效和å��调器移交行为。该æµ�程直接继承é…�置平é�¢å·²è®°å½•的基础é™�制,ä¸�会å�¦åŠ å±€éƒ¨çš„æœºå¯†å˜å‚¨ã€�脱æ•�或设置替æ�¢å�˜é€šæ–¹æ¡ˆã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml
new file mode 100644
index 0000000000..0cd2549e55
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.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-07-30-dsh-dump-config.md
+2026-07-30-dsh-dump-config.md: bc6504541c7868bad019a1bcd9f551435109e4c6
+2026-07-30-dsh-dump-config.zh.md: 5e173305a6cd03de3db4c763f26eeda6fba68ec7
diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md
new file mode 100644
index 0000000000..bc6504541c
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md
@@ -0,0 +1,31 @@
+# Agent Note: dsh --dump-config prints the composed config tree
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-dsh-dump-config.zh.md)
+
+## Problem
+
+The booted tree is a composition the user never sees: the shipped base, a surface overlay, and the `--config` or personal `~/.dsh/config.yaml` overlay apply as sibling patch lists where each id-targeted patch replaces the row's whole `config` and an unmatched id only warns. Debugging a misbehaving personal overlay (a restated field dropped, a row id typo, a patch applying to the wrong surface) required mentally replaying the patch algorithm across three files. There was no way to see the effective tree or to diff it against the shipped defaults.
+
+## Decision
+
+`dsh --dump-config` and `dsh web --dump-config` print the composed entry list — base, surface overlay, then the `--config` or personal overlay, exactly the layers that surface's boot assembles — as YAML on stdout and exit without booting. `dsh --dump-default-config` / `dsh web --dump-default-config` stop at the surface overlay, so diffing the two outputs shows precisely what the user layer changes.
+
+The dump cannot drift from what boots because it shares the mounting code: the vendored include exports its patch algorithm as the pure `applyEntryPatches(data, patches, warn)` (the private `applyPatches` method now delegates to it) and its `!!js` YAML dialect as `entryListSchema`; `dsh-app-boot`'s `renderConfigDump()` composes labeled layers and renders through both, and `apps/cli/src/dump-config.ts` is a thin surface-selection wrapper. `!!js` expressions print verbatim and unevaluated — the dump shows composition, not one process's environment — and a patch whose target row is absent goes to stderr with its layer label, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, web CLI-flag patches, the frontend dist path) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) and each other, and `--dump-default-config` takes no `--config`.
+
+Each run of same-provenance rows is preceded by a `# ==` comment naming the file that contributed the rows and the layers that patched them (`# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows which section comes from which file while remaining one loadable YAML document. Composition is one flattened `applyEntryPatches` call over all layers — boot's exact call shape, so even patch-visibility corner cases (a later layer targeting a group child that a plain `config` replacement introduced, invisible to the single-pass id index) compose identically; applying one call per layer would rebuild the index between layers and print a tree boot never mounts. Provenance is derived from single-call prefix snapshots (base + layers 1..k) diffed positionally: the patch algorithm only rewrites rows in place or appends, so a top-level index identifies one row across snapshots, and a layer counts as having patched a row when adding it changed that row (config replacement, disable, group insert). Patch lists are cloned per snapshot because `applyEntryPatches` pushes `insert` rows by reference from the patch list.
+
+`dsh-app-boot` previously duplicated the include's `!!js` YAML type for patch parsing; it now imports `entryListSchema`, so the dialect has one owner.
+
+## Alternatives considered
+
+**Boot the tree and dump `ctx.loader.entries()`.** Rejected: booting evaluates `!!js` expressions (leaking one machine's environment into the printed config), starts adapters and sessions as side effects, requires a TTY-independent teardown path, and is slow. The dump is for debugging composition, which is a pure function of the files.
+
+**Reimplement the patch merge in the CLI.** Rejected: a second implementation of `applyPatches` would silently drift from the vendored include — the exact failure mode the feature exists to debug. Exporting the include's own algorithm costs one logged vendor modification and guarantees identity.
+
+**A `/dump-config` TUI command instead of flags.** Rejected as the only form: the primary use is a piped `dsh --dump-config | diff - <(dsh --dump-default-config)` style workflow, which needs a boot-free non-TTY surface. A TUI command can be added later over the same `renderConfigDump`.
+
+## Consequences
+
+Config debugging becomes one command instead of mental patch replay, and support can ask for `--dump-config` output. The vendored include carries one more logged local modification (the `applyEntryPatches`/`entryListSchema` exports; behavior-preserving for mounting) to re-apply on upstream sync. Provenance tracking re-composes one prefix snapshot per layer and diffs rows by JSON stringify, so the dump does extra work proportional to layers² × rows; that cost lives only in the boot-free dump path. `renderConfigDump` is unit-tested for layer ordering, verbatim `!!js` round-tripping, provenance separators and grouping, labeled unmatched-patch warnings, and loud read/parse/shape failures; the built-bin e2e drives all four flag forms through `lib/bin.js` including the personal-overlay layer, its provenance label, and its stderr warning.
diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md
new file mode 100644
index 0000000000..5e173305a6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: dsh --dump-config 打å�°å�ˆæˆ�å�Žçš„é…�ç½®æ ‘
+
+Status: implemented
+
+[English](2026-07-30-dsh-dump-config.md) | 䏿–‡
+
+## Problem
+
+å�¯åŠ¨çš„é…�ç½®æ ‘æ˜¯ä¸€ä»½ç”¨æˆ·ä»Žæœªè§�过的å�ˆæˆ�结果:已交付的基础é…�ç½®ã€�界é�¢è¦†ç›–层,以å�Š `--config` 或个人 `~/.dsh/config.yaml` 覆盖层作为å�Œçº§è¡¥ä¸�列表ä¾�æ¬¡åº”ç”¨ï¼Œå…¶ä¸æ¯�个按 id 定å�‘的补ä¸�替æ�¢ç›®æ ‡è¡Œçš„æ•´ä¸ª `config`,未匹é…�çš„ id å�ªäº§ç”Ÿè¦å‘Šã€‚调试一个行为异常的个人覆盖层(æ¼�掉需è¦�é‡�è¿°çš„å—æ®µã€�行 id 拼错ã€�è¡¥ä¸�应用到了错误的界é�¢ï¼‰éœ€è¦�在脑ä¸è·¨ä¸‰ä¸ªæ–‡ä»¶é‡�放补ä¸�ç®—æ³•ã€‚æ—¢æ²¡æœ‰åŠžæ³•çœ‹åˆ°ç”Ÿæ•ˆçš„æ ‘ï¼Œä¹Ÿæ²¡æœ‰åŠžæ³•æŠŠå®ƒä¸Žå·²äº¤ä»˜çš„é»˜è®¤å€¼å�š diff。
+
+## Decision
+
+`dsh --dump-config` å’Œ `dsh web --dump-config` 把å�ˆæˆ�å�Žçš„æ�¡ç›®åˆ—表——基础é…�ç½®ã€�界é�¢è¦†ç›–层ã€�å†�å� `--config` 或个人覆盖层,æ�°å¥½æ˜¯è¯¥ç•Œé�¢å�¯åŠ¨æ—¶ç»„è£…çš„é‚£äº›å±‚â€”â€”ä»¥ YAML 打å�°åˆ° stdout å�Žé€€å‡ºï¼Œä¸�å�¯åŠ¨ä»»ä½•ä¸œè¥¿ã€‚`dsh --dump-default-config` / `dsh web --dump-default-config` æ¢æ¥äºŽç•Œé�¢è¦†ç›–å±‚ï¼Œå› æ¤å¯¹ä¸¤ä»½è¾“出å�š diff 就能精确看出用户层改了什么。
+
+dump ä¸�å�¯èƒ½ä¸Žå®žé™…å�¯åŠ¨æ¼‚ç§»ï¼Œå› ä¸ºå®ƒå¤�用挂载代ç �:vendored include 把补ä¸�算法导出为纯函数 `applyEntryPatches(data, patches, warn)`(ç§�有的 `applyPatches` 方法现在委托给它),并把 `!!js` YAML 方言导出为 `entryListSchema`ï¼›`dsh-app-boot` çš„ `renderConfigDump()` é€šè¿‡è¿™ä¸¤è€…å¯¹å¸¦æ ‡ç¾çš„层完æˆ�å�ˆæˆ�与渲染,`apps/cli/src/dump-config.ts` å�ªæ˜¯é€‰æ‹©ç•Œé�¢çš„è–„å°�装。`!!js` 表达å¼�åŽŸæ ·æ‰“å�°ã€�ä¸�求值——dump 展示的是å�ˆæˆ�结果,ä¸�是æŸ�ä¸ªè¿›ç¨‹çš„çŽ¯å¢ƒâ€”â€”ç›®æ ‡è¡Œä¸�å˜åœ¨çš„è¡¥ä¸�会连å�Œå…¶å±‚æ ‡ç¾æŠ¥åˆ° stderr,与 Loader å�¯åŠ¨æ—¶çš„è¦å‘Šä¸€è‡´ã€‚ç”±å�¯åЍ噍æŒ�有的å�¯åŠ¨ä¸Šä¸‹æ–‡å€¼ï¼ˆä¼šè¯�身份ã€�web çš„ CLI æ ‡å¿—è¡¥ä¸�ã€�å‰�端 dist 路径)是æ¯�次调用的事实,ä½�于é…�ç½®æ ‘ä¹‹å¤–ï¼Œä¸�会出现。dump æ ‡å¿—æ‹’ç»�仅用于å�¯åŠ¨çš„æ ‡å¿—ï¼ˆ`-p`ã€�`--resume`ã€�`--config-replace`)且两个 dump æ ‡å¿—äº’æ–¥ï¼Œ`--dump-default-config` ä¸�接å�— `--config`。
+
+æ¯�段æ�¥æº�相å�Œçš„连ç»è¡Œä¹‹å‰�都有一æ�¡ `# ==` æ³¨é‡Šï¼Œæ ‡æ˜Žè´¡çŒ®è¿™äº›è¡Œçš„æ–‡ä»¶ä»¥å�Šä¿®è¡¥è¿‡å®ƒä»¬çš„层(`# == base.cordis.yml, patched by tui.cordis.yml`ï¼‰ï¼Œå› æ¤è¾“出既展示æ¯�一节æ�¥è‡ªå“ªä¸ªæ–‡ä»¶ï¼Œå�ˆä»�是一份å�¯åŠ è½½çš„ YAML 文档。å�ˆæˆ�是对所有层展平å�Žçš„一次 `applyEntryPatches` 调用——与å�¯åŠ¨çš„è°ƒç”¨å½¢çŠ¶å®Œå…¨ä¸€è‡´ï¼Œå› æ¤å�³ä¾¿æ˜¯è¡¥ä¸�å�¯è§�性的边角情况(å�Žä¸€å±‚定ä½�到å‰�一层通过普通 `config` 替æ�¢å¼•入的组内å�项,而å�•é�� id 索引看ä¸�到它)也与å�¯åЍå�ˆæˆ�完全相å�Œï¼›è‹¥æŒ‰å±‚å�„调用一次,会在层与层之间é‡�建索引,打å�°å‡ºä¸€æ£µå�¯åŠ¨ä»Žä¸�æŒ‚è½½çš„æ ‘ã€‚æ�¥æº�从å�•次调用的å‰�缀快照(基础 + 第 1..k 层)按ä½�ç½® diff 得出:补ä¸�算法å�ªä¼šåŽŸåœ°æ”¹å†™è¡Œæˆ–åœ¨æœ«å°¾è¿½åŠ ï¼Œå› æ¤é¡¶å±‚索引在å�„å¿«ç…§ä¹‹é—´æ ‡è¯†å�Œä¸€è¡Œï¼›åŠ å…¥æŸ�层å�Žè¯¥è¡Œå�‘生å�˜åŒ–(替æ�¢ configã€�ç¦�用ã€�组内æ�’入)å�³è§†ä¸ºè¯¥å±‚修补了这一行。æ¯�个快照都会克隆补ä¸�åˆ—è¡¨ï¼Œå› ä¸º `applyEntryPatches` 会把 `insert` 行按引用从补ä¸�列表推入结果。
+
+`dsh-app-boot` 之�为解�补��制了 include 的 `!!js` YAML 类型;现在改为导入 `entryListSchema`,方言�有一个归属者。
+
+## Alternatives considered
+
+**å�¯åŠ¨æ•´æ£µæ ‘å�Ž dump `ctx.loader.entries()`。** æ‹’ç»�:å�¯åŠ¨ä¼šæ±‚å€¼ `!!js` 表达å¼�(把æŸ�å�°æœºå™¨çš„环境泄æ¼�进打å�°çš„é…�置)ã€�以副作用å�¯åЍ适é…�器和会è¯�ã€�需è¦�独立于 TTY 的拆å�¸è·¯å¾„,而且慢。dump 是用æ�¥è°ƒè¯•å�ˆæˆ�的,而å�ˆæˆ�是那些文件的纯函数。
+
+**在 CLI 里�新实现补��并。** 拒�:`applyPatches` 的第二个实现会与 vendored include 悄然漂移——这��是该功能�调试的失败模�。导出 include 自己的算法�花费一�记录在案的 vendor 修改,���了�一性。
+
+**用 `/dump-config` TUI å‘½ä»¤ä»£æ›¿æ ‡å¿—ã€‚** 作为唯一形å¼�被拒ç»�:主è¦�用法是 `dsh --dump-config | diff - <(dsh --dump-default-config)` 这类管é�“工作æµ�,需è¦�å…�å�¯åЍã€�é�ž TTY 的界é�¢ã€‚之å�Žå�¯ä»¥åœ¨å�Œä¸€ä¸ª `renderConfigDump` 之上å†�åŠ TUI 命令。
+
+## Consequences
+
+é…�置调试从脑ä¸é‡�放补ä¸�å�˜æˆ�一æ�¡å‘½ä»¤ï¼Œæ”¯æŒ�工作也å�¯ä»¥ç›´æŽ¥ç´¢è¦� `--dump-config` 输出。vendored include 多出一æ�¡è®°å½•在案的本地修改(导出 `applyEntryPatches`/`entryListSchema`ï¼›å¯¹æŒ‚è½½è¡Œä¸ºæ— å½±å“�),上游å�Œæ¥æ—¶éœ€é‡�新应用。æ�¥æº�追踪为æ¯�层é‡�æ–°å�ˆæˆ�一次å‰�缀快照并按 JSON stringify 对行å�š diffï¼Œå› æ¤ dump 有与层数²×行数æˆ�æ£æ¯”çš„é¢�外开销;该开销å�ªå˜åœ¨äºŽå…�å�¯åŠ¨çš„ dump 路径。`renderConfigDump` çš„å�•元测试覆盖层å� 顺åº�ã€�`!!js` åŽŸæ ·å¾€è¿”ã€�æ�¥æº�分隔与分组ã€�å¸¦æ ‡ç¾çš„æœªåŒ¹é…�è¡¥ä¸�è¦å‘Šï¼Œä»¥å�Šè¯»å�–ï¼�è§£æž�ï¼�形状失败的大声报错;built-bin e2e 通过 `lib/bin.js` 驱动全部四ç§�æ ‡å¿—å½¢å¼�,包括个人覆盖层ã€�å…¶æ�¥æº�æ ‡ç¾å�Šå…¶ stderr è¦å‘Šã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml
new file mode 100644
index 0000000000..ec0e020502
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-search-render-card.md
+2026-07-30-search-render-card.md: 36f772d7198ef30d6c243549cbaa9f16c780268b
+2026-07-30-search-render-card.zh.md: 7d7ba352f19f3fb83cb6b7d049980776dc2c277e
diff --git a/.agents/notes/implemented/feature/2026-07-30-search-render-card.md b/.agents/notes/implemented/feature/2026-07-30-search-render-card.md
new file mode 100644
index 0000000000..36f772d719
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.md
@@ -0,0 +1,61 @@
+# Agent Note: Search render intent — grep and glob emit a structured search card
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-search-render-card.zh.md)
+
+## Problem
+
+`grep` and `glob` return structured canonical values — `grep` a flat `{ matches: [{ path, lineNumber, line }] }`, `glob` a `{ paths: string[] }` — but every UI only ever saw their model-facing render text: `grep` groups its matches under file headers with `Line N:` rows, `glob` prints a newline-joined path list, and both append a spill footer when the inline cap (`grepMaxMatches`, default 250; `globMaxResults`, default 100) drops later results to a spill file. A web frontend that wants to render a search result as an expandable per-file group of matches, or as a selectable path list, had to re-parse that text. Both tools already declared a call-time [render intent](../architecture/2026-07-02-tool-render-intent-union.md) (`GenericCallView`, `kind: 'search'`) but no result-time view, so the completed call fell back to the generic card that renders the raw text.
+
+The structured canonical value does not cross the wire: only the model-facing render text and, when a tool declares `output.presentationMeta`, a JSON metadata payload reach the client, threaded through the `tool/result` event ([canonical-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). A result-time view carrying structured data therefore has to project that data into `presentationMeta` and read it back in `presentResult` — the same path `write`/`edit` use for their diff cards.
+
+## Decision
+
+`packages/core/tools/src/presentation.ts` adds `card: 'search'` to the `ToolResultView` union as `SearchResultView`, a `shape`-discriminated view that expresses both tools' shapes: `SearchMatchesResultView` (`shape: 'matches'`) carries `grep`'s matches grouped by file as `files: { path, matches: { lineNumber, line }[] }[]`, and `SearchPathsResultView` (`shape: 'paths'`) carries `glob`'s flat `paths: string[]`. Both carry `truncated: boolean` and `total: number`.
+
+The discriminant is `shape`, not `kind`, deliberately: the same presentation module already gives `GenericCallView` a `kind: ToolCallKind` field whose values include `'search'` (the icon category). A bridge holding a `ToolCallView | ToolResultView` would see two `kind` fields with two meanings; `shape` for the result variant keeps the two apart.
+
+One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `shape` for the row layout. The discriminated `shape` keeps each variant's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
+
+The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op for every consumer (the TUI already falls back to `result.content`, and web fallbacks read the raw `tool/result` content), and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw `tool/result` content.
+
+The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
+
+`packages/fs/tool-fs-search/src/presentation.ts` owns the projection and the narrowing. `grepSearchMeta`/`globSearchMeta` project the canonical value into a `SearchMeta` payload each tool declares as `output.presentationMeta`; `presentGrepResult`/`presentGlobResult` read `result.meta` back through `searchViewFromMeta`. They consume the SAME retained result the model-facing render consumes — `retainGrepMatches`/`retainGlobPaths` in `search-core.ts` run the inline cap and per-line preview budget ONCE, and both the render and the projection take that outcome — so text and card never disagree about which results survived, and there is no second retention pass. `total` is every result the search found (before capping); `truncated` is set when the cap dropped results. This is the truncation-honesty point: the model saw a capped inline result plus a spill footer, so the card must not present the retained page as the complete result — a UI reads `truncated`/`total` to show a capped indicator rather than claiming completeness the model never had.
+
+**The meta has its own byte budget.** The inline cap bounds the item COUNT, but the retained matches of a broad search (hundreds of long lines) can still serialize to hundreds of kilobytes, and `meta` is persisted with the session log and re-sent on every request. A deployment's final output budget (`dsh-spill-policy`, `maxInlineBytes`) only shrinks a result's `content` — `PostToolDecision` has no `meta` channel — so the projection owns keeping `meta` bounded. `capMetaBytes` drops trailing file groups / paths until the serialized meta fits `searchMetaMaxBytes` (config, default 64 KiB) and marks the result `truncated`. A single item too large to fit on its own is kept: the invariant is a bounded payload wherever droppable, never an empty card that hides a real result.
+
+`searchViewFromMeta` narrows the opaque `meta` defensively and returns `undefined` on any malformed or absent payload, so a presenter run on an older or hand-edited replayed log falls back to the generic card instead of throwing. It DOES accept a zero-result payload (`files: []` / `paths: []`) as a valid empty card — this is a deliberate departure from the mirrored `diffsFromMeta`, which rejects empty `diffs`, because a zero-match grep is a legitimate result a UI shows as "no matches", not an absent projection. `presentResult` returns `undefined` for a failed result, for absent meta (a nested `run_code` dispatch computes no `presentationMeta`), and for the other tool's meta shape (each presenter narrows to its own `shape`).
+
+The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
+
+The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly, and a `search` view falls through to the same dim generic body, reading the model-facing text from `this.result?.content`. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, the TUI output stays byte-identical to the pre-search-card fallback. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers.
+
+## Alternatives considered
+
+**A single flat `SearchResultView` interface with optional `files?` and `paths?`.** Rejected: it makes both shape-specific fields optional on every value and lets a malformed view carry both or neither. The `shape` discriminant keeps each variant's fields required and lets a consumer switch exhaustively.
+
+**Reuse `kind` as the shape discriminant.** Rejected: `kind` already means `ToolCallKind` (the icon category, whose values include `'search'`) on the call view in the same module. A second `kind` with a different meaning on the result view collides for any bridge holding both.
+
+**Attach the model-facing text as the view's `content`.** Rejected: a no-op for every current consumer and a second serialization of the whole search text into the persisted view. The view is the structured shape; text fallback reads the raw result content.
+
+**A meta channel on `PostToolDecision` so `dsh-spill-policy` bounds `meta` like it bounds `content`.** Rejected for this PR: it changes the core tool decision contract and the spill-policy plugin for one tool's payload. The projection bounding its own `meta` at a config byte cap is self-contained and keeps the seam unchanged.
+
+**A call-time `SearchCallView` mirroring the terminal card's both-sides symmetry.** Rejected: a search call has no matches or paths before `execute`, so the view would carry only the title the `GenericCallView` already carries.
+
+## Consequences
+
+`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log.
+
+A UI without a search card renders the raw `tool/result` content, so no consumer regresses, and the TUI stays byte-identical. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
+
+## Testing
+
+`packages/fs/tool-fs-search/tests/presentation.spec.ts` pins the pure layer: `groupMatchesByFile`'s first-seen file order; `grepSearchMeta`/`globSearchMeta` projection over a shared retention outcome with `total` reporting the pre-cap count and `truncated` carried through; the per-line preview budget the retention pass applied; the serialized-meta byte cap dropping trailing groups/paths while keeping a single oversized item; and `searchViewFromMeta`'s narrowing of both good shapes, the zero-result empty card, and every malformed case (non-object/array meta, missing or mistyped `truncated`/`total`, unknown `shape`, malformed `files` entries, non-string `paths`). `packages/fs/tool-fs-search/tests/tools.spec.ts` pins the wiring through the real tool registry: a capped `grep`/`glob` execute produces the `SearchMeta` on `result.meta` and `presentResult` builds the search view (no `content`), a nested `run_code` dispatch computes no meta so `presentResult` falls back, and a failed or cross-shape or malformed result falls back to the generic card. Per-file 100% coverage holds over the search package `src`.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `search` result tag.
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — the value/render/`presentationMeta` split this projection rides; the structured value stays execution-local, the card rides `meta`.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors on the backend: a tool projects its result into `presentationMeta` and a `presentResult` view; the search card's web consumer is the analogous follow-up.
diff --git a/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
new file mode 100644
index 0000000000..7d7ba352f1
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
@@ -0,0 +1,61 @@
+# Agent Note:�索渲染�图 —— grep 与 glob 产出结构化�索�片
+
+Status: implemented
+
+[English](2026-07-30-search-render-card.md) | 䏿–‡
+
+## 问题
+
+`grep` 与 `glob` 返回结构化的 canonical 值 —— `grep` 是æ‰�平的 `{ matches: [{ path, lineNumber, line }] }`,`glob` 是 `{ paths: string[] }` —— 但æ¯�个 UI å�ªè§�过它们é�¢å�‘模型的渲染文本:`grep` 把匹é…�按文件头分组ã€�æ¯�行 `Line N:`,`glob` 打å�°æ�¢è¡Œè¿žæŽ¥çš„路径列表,两者在内è�”上é™�(`grepMaxMatches`,默认 250ï¼›`globMaxResults`,默认 100)把å�Žç»ç»“æžœè�½åˆ° spill æ–‡ä»¶æ—¶éƒ½è¿½åŠ ä¸€ä¸ª spill 脚注。想把æ�œç´¢ç»“果渲染æˆ�å�¯å±•开的按文件匹é…�组ã€�或å�¯é€‰æ‹©çš„路径列表的 web å‰�端,å�ªèƒ½åŽ»é‡�æ–°è§£æž�那段文本。两个工具都已声明调用时的[渲染æ„�图](../architecture/2026-07-02-tool-render-intent-union.md)(`GenericCallView`,`kind: 'search'`),但没有结果时视图,所以已完æˆ�的调用回退到渲染原始文本的 generic å�¡ç‰‡ã€‚
+
+结构化 canonical 值ä¸�è·¨çº¿ä¼ è¾“ï¼šå�ªæœ‰é�¢å�‘模型的渲染文本ã€�以å�Šå½“工具声明了 `output.presentationMeta` 时的一份 JSON 元数æ�®ï¼Œä¼šç»� `tool/result` 事件到达客户端([canonical-output 契约](../architecture/2026-07-20-canonical-tool-output-contract.md)ï¼‰ã€‚å› æ¤æ�ºå¸¦ç»“构化数æ�®çš„结果时视图必须把数æ�®æŠ•影进 `presentationMeta`,å†�在 `presentResult` 里读回 —— 与 `write`/`edit` çš„ diff å�¡ç‰‡èµ°å�Œä¸€æ�¡è·¯ã€‚
+
+## 决定
+
+`packages/core/tools/src/presentation.ts` 把 `card: 'search'` 作为 `SearchResultView` åŠ å…¥ `ToolResultView` è�”å�ˆï¼Œè¿™æ˜¯ä¸€ä¸ªä»¥ `shape` 判别的视图,表达两个工具的形状:`SearchMatchesResultView`(`shape: 'matches'`)以 `files: { path, matches: { lineNumber, line }[] }[]` 承载 `grep` 按文件分组的匹é…�,`SearchPathsResultView`(`shape: 'paths'`)承载 `glob` çš„æ‰�å¹³ `paths: string[]`。两者都带 `truncated: boolean` 与 `total: number`。
+
+判别å�是 `shape` 而é�ž `kind`,是刻æ„�为之:å�Œä¸€ä¸ª presentation 模å�—å·²ç»�ç»™ `GenericCallView` 一个 `kind: ToolCallKind` å—æ®µï¼Œå…¶å�–值æ�°å¥½åŒ…å�« `'search'`ï¼ˆå›¾æ ‡ç±»åˆ«ï¼‰ã€‚æŒ�有 `ToolCallView | ToolResultView` 的桥接层会看到两个å�«ä¹‰ä¸�å�Œçš„ `kind` å—æ®µï¼›ç»“æžœå�˜ä½“用 `shape` 把两者分开。
+
+用一个带两ç§�形状的视图而é�žä¸¤å¼ å�¡ç‰‡ï¼Œå› 为两个工具是å�Œä¸€ä¸ªè§†è§‰å¯¹è±¡ —— 一个æ�œç´¢ç»“æžœ —— web 消费方先在一个 `card` 值上分支,å†�在 `shape` 上分支决定行布局。判别å¼� `shape` 让æ¯�个å�˜ä½“çš„å—æ®µä¿�æŒ�é�žå�¯é€‰ï¼ˆmatches 视图总有 `files`,paths 视图总有 `paths`),而ä¸�æ˜¯ä¸€ä¸ªæ‰€æœ‰å½¢çŠ¶ç›¸å…³å—æ®µéƒ½å�¯é€‰çš„å�•一接å�£ã€‚
+
+该视图**ä¸�**æ�ºå¸¦ç»“果文本。早期版本曾把é�¢å�‘模型的 `result.content` 附到视图上;那对æ¯�个消费方都是 no-op(TUI 本就回退到 `result.content`,web 回退读原始 `tool/result` 内容),å�´æŠŠæ•´æ®µæ�œç´¢æ–‡æœ¬å�ˆåº�列化进æŒ�久化视图一é��。视图å�ªæ‰¿è½½ç»“æž„åŒ–å½¢çŠ¶ï¼›æ— search å�¡ç‰‡çš„ UI 回退到原始 `tool/result` 内容。
+
+å�¡ç‰‡æ ‡ç¾å�ªåœ¨ç»“果时å˜åœ¨ã€‚æ�œç´¢è°ƒç”¨ä¿�æŒ�为 `GenericCallView`(`kind: 'search'`):pending 状æ€�没有匹é…�或路径å�¯å±•示,所以 `SearchCallView` 能æ�ºå¸¦çš„东西ä¸�会比 generic æ ‡é¢˜æ›´å¤šã€‚è¿™æ˜¯ä¸Ž terminal å�¡ç‰‡çš„ä¸�对称之处 —— terminal 的调用视图æ�ºå¸¦æ‰§è¡Œå‰�å°±å˜åœ¨çš„命令ã€�cwdã€�descriptionï¼›æ�œç´¢çš„结构化内容å�ªåœ¨ `execute` 之å�Žæ‰�å˜åœ¨ã€‚
+
+`packages/fs/tool-fs-search/src/presentation.ts` 拥有投影与收窄。`grepSearchMeta`/`globSearchMeta` 把 canonical 值投影为æ¯�个工具声明为 `output.presentationMeta` çš„ `SearchMeta` è½½è�·ï¼›`presentGrepResult`/`presentGlobResult` ç»� `searchViewFromMeta` 把 `result.meta` 读回。它们消费与é�¢å�‘模型渲染相å�Œçš„å·²ä¿�留结果 —— `search-core.ts` 里的 `retainGrepMatches`/`retainGlobPaths` å�ªè·‘一次内è�”上é™�与æ¯�行预览预算,render 与投影都å�–这份产出 —— 所以文本与å�¡ç‰‡å¯¹å“ªäº›ç»“æžœå¹¸å˜æ°¸ä¸�分æ§ï¼Œä¹Ÿæ²¡æœ‰ç¬¬äºŒæ¬¡ä¿�留计算。`total` 是æ�œç´¢æ‰¾åˆ°çš„全部结果(截æ–å‰�);`truncated` 在上é™�丢弃了结果时置ä½�。这是截æ–诚实点:模型看到的是被截æ–的内è�”ç»“æžœåŠ ä¸€ä¸ª spill 脚注,所以å�¡ç‰‡ä¸�能把ä¿�留页当作完整结果 —— UI 读 `truncated`/`total` æ˜¾ç¤ºæˆªæ–æŒ‡ç¤ºï¼Œè€Œé�žå®£ç§°æ¨¡åž‹ä»Žæœªæœ‰è¿‡çš„完整性。
+
+**meta 有自己的å—节预算。** 内è�”上é™�约æ�Ÿçš„æ˜¯æ�¡ç›®æ•°ï¼Œä½†ä¸€æ¬¡å®½æ³›æ�œç´¢ä¿�留下æ�¥çš„匹é…�(数百æ�¡é•¿è¡Œï¼‰ä»�å�¯åº�列化到数百 KB,而 `meta` 会éš�会è¯�日志æŒ�久化并在æ¯�次请求时é‡�å�‘。部署的最终输出预算(`dsh-spill-policy` çš„ `maxInlineBytes`)å�ªç¼©å‡�结果的 `content` —— `PostToolDecision` 没有 `meta` 通é�“ —— 所以投影自己负责把 `meta` 约æ�Ÿä½�。`capMetaBytes` 丢弃末尾的文件组ï¼�路径,直到åº�列化 meta 装进 `searchMetaMaxBytes`(é…�置,默认 64 KiBï¼‰ï¼Œå¹¶æŠŠç»“æžœæ ‡è®° `truncated`。å�•个大到自身都装ä¸�下的æ�¡ç›®ä¼šè¢«ä¿�留:ä¸�å�˜é‡�是å�¯ä¸¢å¼ƒå¤„一律有界,ç»�ä¸�产出éš�è—�了真实结果的空å�¡ç‰‡ã€‚
+
+`searchViewFromMeta` 防御性地收窄ä¸�é€�明的 `meta`,对任何畸形或缺失载è�·è¿”回 `undefined`,使在较旧或手工编辑的回放日志上è¿�行的 presenter 回退到 generic å�¡ç‰‡è€Œé�žæŠ›é”™ã€‚它确实接å�—零结果载è�·ï¼ˆ`files: []` / `paths: []`)为å�ˆæ³•的空å�¡ç‰‡ —— 这是与被镜åƒ�çš„ `diffsFromMeta` 的刻æ„�å��离(å�Žè€…æ‹’ç»�空 `diffs`ï¼‰ï¼Œå› ä¸ºé›¶åŒ¹é…�çš„ grep 是 UI 展示为「no matchesã€�çš„å�ˆæ³•结果,而é�žç¼ºå¤±çš„æŠ•影。`presentResult` 对失败结果ã€�对缺失 meta(嵌套 `run_code` 分å�‘ä¸�计算 `presentationMeta`)ã€�以å�Šå¯¹å�¦ä¸€å·¥å…·çš„ meta 形状(æ¯�个 presenter 收窄到自己的 `shape`)返回 `undefined`。
+
+`SearchMeta` çš„æˆ�员形状是对象å—é�¢é‡� `type` 别å��,而é�žè§†å›¾æš´éœ²çš„ `SearchFileMatches`/`SearchLineMatch` 接å�£ï¼Œå› 为å�ªæœ‰ type 别å��å�¯èµ‹ç»™ `presentationMeta` 返回的 `JsonValue` 索引ç¾å��;两者结构ç‰ä»·ï¼Œæ‰€ä»¥æŠ•影值ä»�读回为 `SearchResultView`。
+
+TUI(`packages/ui/tui/src/components/transcript.ts`)ä¸�需è¦�专门分支:它的结果视图 switch 显å¼�处ç�† `terminal` 与 `diff`,`search` 视图è�½å…¥å�Œä¸€ä¸ªå�˜æš—çš„ generic body,从 `this.result?.content` 读å�–é�¢å�‘æ¨¡åž‹çš„æ–‡æœ¬ã€‚å› ä¸ºæ�œç´¢è§†å›¾ä¸�带自己的 `content`,而本 PR 之å‰� grep/glob 返回的是 generic å�¡ç‰‡ï¼Œæ‰€ä»¥ TUI è¾“å‡ºä¸Žæ— search å�¡ç‰‡çš„回退é€�å—节一致。渲染结构化 `files`/`paths` 形状的 web å‰�端是å�¦ä¸€ä¸ªå�Žç» PR;本 PR 是å�Žç«¯å¥‘约å�Šå…¶ä¸¤ä¸ªç”Ÿäº§è€…。
+
+## 考虑过的备选
+
+**一个æ‰�平的 `SearchResultView` 接å�£ï¼Œå¸¦å�¯é€‰ `files?` 与 `paths?`。** å�¦å†³ï¼šå®ƒè®©ä¸¤ä¸ªå½¢çŠ¶ç›¸å…³å—æ®µåœ¨æ¯�个值上都å�¯é€‰ï¼Œå¹¶å…�许畸形视图å�Œæ—¶å¸¦ä¸¤è€…或都ä¸�带。`shape` 判别å¼�让æ¯�个å�˜ä½“çš„å—æ®µä¿�æŒ�必需,并让消费方穷尽分支。
+
+**å¤�用 `kind` 作形状判别å�。** å�¦å†³ï¼šå�Œä¸€æ¨¡å�—里调用视图上的 `kind` å·²ç»�表示 `ToolCallKind`ï¼ˆå›¾æ ‡ç±»åˆ«ï¼Œå�–值å�« `'search'`)。结果视图上å†�有一个å�«ä¹‰ä¸�å�Œçš„ `kind`,对任何å�Œæ—¶æŒ�有两者的桥接层都会冲çª�。
+
+**把��模型的文本作为视图的 `content` 附上。** �决:对�个当�消费方是 no-op,且把整段�索文本第二次�列化进�久化视图。视图是结构化形状;文本回退读原始结果内容。
+
+**在 `PostToolDecision` ä¸ŠåŠ meta 通é�“,让 `dsh-spill-policy` åƒ�约æ�Ÿ `content` é‚£æ ·çº¦æ�Ÿ `meta`。** 本 PR å�¦å†³ï¼šå®ƒä¸ºä¸€ä¸ªå·¥å…·çš„è½½è�·æ”¹åŠ¨æ ¸å¿ƒå·¥å…·å†³ç–契约与 spill-policy æ�’件。投影在é…�ç½®å—节上é™�处约æ�Ÿè‡ªå·±çš„ `meta` 是自包å�«çš„,且ä¿�æŒ� seam ä¸�å�˜ã€‚
+
+**镜åƒ� terminal å�¡ç‰‡å�Œä¾§å¯¹ç§°çš„调用时 `SearchCallView`。** å�¦å†³ï¼šæ�œç´¢è°ƒç”¨åœ¨ `execute` å‰�没有匹é…�或路径,视图å�ªä¼šæ�ºå¸¦ `GenericCallView` å·²æœ‰çš„æ ‡é¢˜ã€‚
+
+## �果
+
+`grep` 与 `glob` 现在在æ¯�次é�žåµŒå¥—çš„æˆ�功调用上计算 `presentationMeta`,这是对已ä¿�留匹é…�或路径的一次有界投影 —— 与 render 消费的是å�Œä¸€ä»½ä¿�留产出,所以没有第二次ä¿�留计算,线上也没有翻å€�çš„æ�œç´¢æ–‡æœ¬ã€‚åº�列化 meta å�— `searchMetaMaxBytes` 约æ�Ÿï¼Œæ‰€ä»¥å®½æ³›æ�œç´¢ä¸�å†�æŠŠæ— ç•Œçš„ç»“æž„åŒ–å‰¯æœ¬æŒ�久化进会è¯�日志。
+
+æ— search å�¡ç‰‡çš„ UI 渲染原始 `tool/result` 内容,所以没有消费方退化,TUI 也é€�å—节一致。渲染结构化形状的 web 消费方读 `truncated`/`total` ä¸ŽæŒ‰æ–‡ä»¶åˆ†ç»„ï¼›å› ä¸ºè§†å›¾å�ªæ�ºå¸¦ä¿�留的ã€�å—节有界的页,想è¦�完整结果的 UI è·Ÿéš�é�¢å�‘模型文本里的 spill 定ä½�符,与模型的å�šæ³•å®Œå…¨ä¸€è‡´ã€‚
+
+## 测试
+
+`packages/fs/tool-fs-search/tests/presentation.spec.ts` é’‰ä½�纯层:`groupMatchesByFile` 的首è§�文件顺åº�ï¼›`grepSearchMeta`/`globSearchMeta` 在共享ä¿�留产出上的投影,`total` 报告截æ–å‰�计数ã€�`truncated` 被带过;ä¿�ç•™è¿‡ç¨‹æ–½åŠ çš„æ¯�行预览预算;åº�列化 meta å—节上é™�丢弃末尾组ï¼�路径å�Œæ—¶ä¿�ç•™å�•个超大æ�¡ç›®ï¼›ä»¥å�Š `searchViewFromMeta` 对两ç§�良好形状ã€�零结果空å�¡ç‰‡ã€�以å�Šæ¯�ç§�畸形情形(é�žå¯¹è±¡ï¼�数组 metaã€�缺失或误型的 `truncated`/`total`ã€�未知 `shape`ã€�畸形 `files` æ�¡ç›®ã€�é�žå—符串 `paths`)的收窄。`packages/fs/tool-fs-search/tests/tools.spec.ts` é’‰ä½�ç»�真实工具注册表的接线:被截æ–çš„ `grep`/`glob` execute 在 `result.meta` 上产出 `SearchMeta`,`presentResult` 构建æ�œç´¢è§†å›¾ï¼ˆæ— `content`),嵌套 `run_code` 分å�‘ä¸�计算 meta æ•… `presentResult` 回退,失败或跨形状或畸形结果回退到 generic å�¡ç‰‡ã€‚æ�œç´¢åŒ… `src` 上ä¿�æŒ� per-file 100% 覆盖。
+
+## 相关
+
+- [å·¥å…·è°ƒç”¨å‘ˆçŽ°çš„å¸¦æ ‡ç¾æ¸²æŸ“æ„�图è�”å�ˆ](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 PR 用 `search` ç»“æžœæ ‡ç¾æ‰©å±•çš„ `card` æ ‡ç¾è¯�汇。
+- [Canonical 工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投影所乘的 value/render/`presentationMeta` 划分;结构化值留在执行本地,�片乘 `meta`。
+- [Web terminal å�¡ç‰‡](2026-07-28-web-terminal-card.md) —— 本 PR 在å�Žç«¯é•œåƒ�的先例:工具把结果投影进 `presentationMeta` 与一个 `presentResult` 视图;æ�œç´¢å�¡ç‰‡çš„ web 消费方是与之类比的å�Žç»ã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml
new file mode 100644
index 0000000000..579586f30a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.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-07-30-versioned-gui-welcome-onboarding.md
+2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af
+2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7
diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
new file mode 100644
index 0000000000..8155838f3b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
@@ -0,0 +1,35 @@
+# Agent Note: Versioned GUI welcome onboarding
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-versioned-gui-welcome-onboarding.zh.md)
+
+## Problem
+
+The GUI's credential onboarding begins with a DeepSeek-specific readiness check, but the internal-test notice applies to every user and must precede provider setup even when a credential is already configured. Treating both as independent overlays permits simultaneous dialogs, while a process-local dismissal cannot distinguish a completed notice from a window closed before acknowledgement or intentionally present revised copy once.
+
+## Decision
+
+**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. The product welcome registers at order `-100`, while `ui-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`.
+
+**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
+
+**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once.
+
+**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
+
+**Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists.
+
+## Alternatives considered
+
+**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream.
+
+**A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list.
+
+**Persisting on render or window close** — rejected because observation is not acknowledgement and close delivery is unreliable. Only the explicit Continue commit may suppress the next launch.
+
+**A generic public settings-exposure flag** — rejected because one product namespace does not justify widening every settings registrant's public configuration surface. The gateway keeps an explicit closed allowlist.
+
+## Consequences
+
+A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console.
diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md
new file mode 100644
index 0000000000..c221a6d663
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 版本化 GUI 欢迎引导
+
+Status: implemented
+
+[English](2026-07-30-versioned-gui-welcome-onboarding.md) | 䏿–‡
+
+## 问题
+
+GUI çš„å‡æ�®å¼•导从 DeepSeek 专用的就绪状æ€�检查开始,但内部测试通知适用于æ¯�ä½�用户,å�³ä½¿å‡æ�®å·²ç»�é…�置,也必须先于æ��供方设置显示。若把两者作为独立浮层处ç�†ï¼Œå¤šä¸ªå¯¹è¯�框å�¯èƒ½å�Œæ—¶å‡ºçŽ°ï¼›ä»…å˜äºŽè¿›ç¨‹å†…çš„å…³é—æ ‡è®°æ—¢æ— 法区分通知已完æˆ�确认还是窗å�£åœ¨ç¡®è®¤å‰�已关é—ï¼Œä¹Ÿæ— æ³•åœ¨æ–‡æ¡ˆæœ‰æ„�修订å�Žé‡�新显示一次通知。
+
+## 决ç–
+
+**设置外壳å��调有åº�æ¥éª¤ã€‚** `settings.onboarding` ä»�æ˜¯æ ¹ä½œç”¨åŸŸ list,但 `ui-settings` 会把其ä¸å�„æ�¡ç›®çš„ id 和顺åº�投影到一个å��调器ä¸ï¼Œå¹¶ä¸”å�ªæŒ‚载第一个未完æˆ�çš„æ¥éª¤ã€‚当å‰�注册方会收到 `complete()` å’Œ `openSection(id)`;所有æ�ƒè½¬ç§»å‰�,ä¸�会挂载å�Žç»æ¥éª¤ã€‚产å“�欢迎æ¥éª¤çš„顺åº�为 `-100`,`ui-models` 则å�ªä¿�留顺åº�为 `0` çš„ DeepSeek æ�¡ä»¶å¼�就绪状æ€�ä¸Žå‡æ�®è·³è½¬æ¥éª¤ã€‚
+
+**ä¸�属于å�•一功能的产å“�引导由 `ui-settings-general` æŒ�有。** `src/onboarding-copy.ts` 是完整通知ã€�「继ç»ã€�按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一å�¯ç¼–辑æ�¥æº�ï¼›GUI 支æŒ�的两ç§� locale 都有æ„�渲染å�Œä¸€ä»½ä¸æ–‡æ‰€æœ‰è€…文案。è¿�行时 locale å—典从该文件派生欢迎文案,测试也导入å�Œä¸€ä¸ªæ‰€æœ‰è€…,而ä¸�é‡�å¤�段è�½æ–‡æœ¬ã€‚该通知å�ªå˜åœ¨äºŽæµ�览器 UI:它ä¸�会创建会è¯�事件,也ä¸�会贡献任何模型å�¯è§�内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为é�¥æµ‹å…³é—æ–¹å¼�。
+
+**确认状æ€�按 Harness profile æŒ�久化。** 宿主端在 user-settings seam 䏿³¨å†Œ `ui-onboarding` 分节,并å˜å…¥å½“å‰� `$DSH_HOME/settings.yaml`。除é�ž `welcomeNoticeVersion` 与文案所有者文件ä¸çš„常é‡�精确相ç‰ï¼Œå�¦åˆ™æµ�览器会显示通知。「继ç»ã€�会以当å‰�版本执行一次路径å�˜æ›´ï¼Œå¹¶ä¸”仅在宿主端æ��交æˆ�功å�Žè°ƒç”¨ `complete()`;写入失败时通知ä¿�æŒ�打开,关é—页é�¢æˆ–进程则ä¸�会写入任何内容。æ��å�‡è¯¥å¸¸é‡�会有æ„�è¦�求æ¯�个 profile 对修订å�Žçš„æ–‡æ¡ˆé‡�新确认一次。
+
+**å¹¶å�‘è§†å›¾æ— éœ€é™ˆæ—§çš„æ•´ä½“æ›¿æ�¢å�³å�¯æ”¶æ•›ã€‚** 确认写入有æ„�çœ�ç•¥ `expectedRevision`:æ¯�ä¸ªæ ‡ç¾é¡µéƒ½å�‘å�Œä¸€è·¯å¾„写入相å�Œç‰ˆæœ¬ï¼Œå› æ¤è¯¥æ“�作是幂ç‰çš„,并会ä¿�ç•™å�Œçº§å—段,而ä¸�是é‡�建整个分节。`settings/document-updated` 会转为 `host/settings-changed`ï¼›å�¦ä¸€ä¸ªæ ‡ç¾é¡µæˆ–外部编辑器æ��交当å‰�版本å�Žï¼Œå·²æŒ‚è½½çš„æ ‡ç¾é¡µä¼šé‡�新拉å�–状æ€�并推进。API 网关在å�¯é…�ç½®æ��供方 namespace 之外,通过å°�é—çš„å…�许列表暴露这一个产å“� namespace,å�Œæ—¶ä¸�会把它的å�˜æ›´è§†ä¸ºæ¨¡åž‹ç›®å½•失效事件。
+
+**引导æµ�程会暂时接管视å�£ï¼Œå½¢æˆ�一个连ç»é˜¶æ®µã€‚** 纯色产å“�界é�¢é€šè¿‡æŒ‚载到 `body` çš„ portal å�–ä»£å®Œæ•´çš„åº”ç”¨è§†å›¾ï¼Œå¹¶å°†åº•å±‚åº”ç”¨æ ¹èŠ‚ç‚¹æ ‡è®°ä¸º inertï¼›ä¸¥æ ¼ç¬¦å�ˆè¦�求的é�®ç½©ä»�挂载在该界é�¢å�Žæ–¹ï¼Œå¹¶ä¿�ç•™ `position:absolute`ã€�left/right/bottom å��ç§»é‡�为零ã€�`top:80px`ã€�`rgba(0, 0, 0, 0.24)` å’Œ `backdrop-filter: blur(2px)`。欢迎页和按æ�¡ä»¶æ˜¾ç¤ºçš„凿�®è®¾ç½®é¡µåœ¨è¿™ä¸€é˜¶æ®µä¸ä¾�次呈现,而ä¸�是å�„自作为独立的模æ€�窗å�£ã€‚两个页é�¢éƒ½å¤�用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` æ ‡é¢˜ä¸‹é€�å—ä¿�留既定的四段文案;所有段è�½ç»Ÿä¸€é‡‡ç”¨ 16/28 çš„æ£æ–‡å—å�·ä¸Žè¡Œé«˜ï¼Œå�ªæœ‰æœ€å�Žä¸€æ®µä¸æŒ‡å®šçš„行动è¯å�¥ä½¿ç”¨è¾ƒä¸ºå…‹åˆ¶çš„ 500 å—é‡�ã€‚çŸæš‚的错è�½å¼�é€�明度与纵å�‘ä½�移动画è�¥é€ 出舒缓节å¥�,但ä¸�会阻ç¢�交互,并会在用户å�¯ç”¨å‡�少动æ€�效果时ç¦�用。åˆ�始焦点è�½åœ¨æ ‡é¢˜ä¸Šï¼Œã€Œç»§ç»ã€�是唯一按钮,且ä¸�å˜åœ¨å…³é—ã€�Escape 或点击é�®ç½©çš„退出路径。
+
+## 曾考虑的替代方案
+
+**æµ�览器本地å˜å‚¨**:ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºç¡®è®¤çŠ¶æ€�会跟éš�æŸ�个æµ�览器 profile,而ä¸�是 `$DSH_HOME`;全新的 Harness profile å�¯èƒ½é”™è¯¯ç»§æ‰¿æ¤å‰�的确认状æ€�,外部 profile 编辑也没有æ�ƒå¨�æ›´æ–°æµ�。
+
+**在 `ui-settings-general` ä¸å†�å¢žåŠ ä¸€ä¸ªç‹¬ç«‹æ¨¡æ€�窗å�£**:ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºæ¬¢è¿Žé€šçŸ¥å’Œå‡æ�®å°±ç»ªçжæ€�å�Œæ—¶ä¸ºçœŸæ—¶ï¼Œlist 注册方ä»�ä¼šå †å� 。声明并渲染该 list 的外壳应当æŒ�有有åº�所有æ�ƒã€‚
+
+**在渲染或窗å�£å…³é—æ—¶æŒ�久化**:ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºçœ‹è§�通知ä¸�ç‰äºŽç¡®è®¤ï¼Œçª—å�£å…³é—äº‹ä»¶ä¹Ÿæ— æ³•å�¯é� é€�达。å�ªæœ‰æ˜¾å¼�æ��交「继ç»ã€�æ‰�能阻æ¢é€šçŸ¥åœ¨ä¸‹æ¬¡å�¯åŠ¨æ—¶å†�次显示。
+
+**é€šç”¨çš„å…¬å¼€è®¾ç½®æš´éœ²æ ‡å¿—**:ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºä¸€ä¸ªäº§å“� namespace ä¸�足以è¯�明应当扩大æ¯�个 settings 注册方的公开é…�ç½®é�¢ã€‚网关ä¿�留显å¼�çš„å°�é—å…�许列表。
+
+## �果
+
+全新 profile 始终会在æ��供方专用引导之å‰�çœ‹åˆ°æ¬¢è¿Žé€šçŸ¥ï¼›å‡æ�®å·²ç»�é…�置时,å�ªä¼šè·³è¿‡å�Žç» DeepSeek æ¥éª¤ã€‚点击「继ç»ã€�å�Žé‡�æ–°åŠ è½½ä¸�会å†�次显示已确认版本,更改文案所有者文件ä¸çš„版本值会让通知é‡�新出现,而确认å‰�å…³é—窗å�£ä¸�会改å�˜ä¸‹æ¬¡å�¯åŠ¨ã€‚é’ˆå¯¹æ€§çš„ store 与 React 测试固化了精确版本比较ã€�写入失败ã€�å�•一æ“�作ã€�ä¸�å�¯å…³é—路径ã€�å��调器顺åº�ã€�按æ�¡ä»¶ç§»äº¤ DeepSeek æ¥éª¤å’Œ HMRï¼ˆçƒæ¨¡å�—替æ�¢ï¼‰æ¸…ç�†è¡Œä¸ºã€‚真实 Chromium 场景会使用隔离的 harness 家目录å�¯åЍéš�产å“�æ��供的 Web 组å�ˆï¼ŒéªŒè¯�é�®ç½©çš„ç²¾ç¡®å‡ ä½•å°ºå¯¸å’Œè®¡ç®—æ ·å¼�,在确认å‰�å�Žåˆ†åˆ«é‡�æ–°åŠ è½½ï¼Œç»§ç»è¿›å…¥å‡æ�®ç¼ºå¤±è®¾ç½®æµ�ç¨‹ï¼Œç¡®è®¤å‡æ�®å·²é…�置时确认版本ä¸�匹é…�ä»�会使通知é‡�新出现,并检查æµ�览器控制å�°ã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
new file mode 100644
index 0000000000..f9b2cfa908
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
+2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c
+2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
new file mode 100644
index 0000000000..396bdbc284
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
@@ -0,0 +1,57 @@
+# Agent Note: Web diff card — the write/edit render intent reaches the browser
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-web-diff-card.zh.md)
+
+## Problem
+
+The `write` and `edit` tools declare `card: 'diff'` for both their call and their result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the intended change derived from the arguments, and the result view carries the applied contextual hunks (`FileDiff[]`, computed by `packages/fs/tool-fs/src/diff.ts` and persisted in the result `meta` so replay reproduces it). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as per-file `+`/`-` blocks with a `+A -R · N file(s)` footer.
+
+The Web client ignored it. A write/edit call landed on `GenericToolCard`, whose row is derived from raw tool args, and the details panel flattened the result's content blocks into one ``. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
+
+This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
+
+## Decision
+
+`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
+
+The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
+
+- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
+- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
+- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
+- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
+- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
+
+Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
+
+The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
+
+## Alternatives considered
+
+**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
+
+**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
+
+**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
+
+## Consequences
+
+`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
+
+The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns.
+
+## Related
+
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
new file mode 100644
index 0000000000..afdeafa6e9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
@@ -0,0 +1,57 @@
+# Agent Note: Web diff �片 —— write/edit 渲染�图抵达�览器
+
+Status: implemented
+
+[English](2026-07-30-web-diff-card.md) | 䏿–‡
+
+## Problem
+
+`write` å’Œ `edit` 工具为其 call å’Œ result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view æ�ºå¸¦ä»Žå�‚数推导的预期改动,result view æ�ºå¸¦å·²åº”用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并æŒ�久化在 result `meta` ä¸ä»¥ä¾¿å›žæ”¾é‡�建)。该视图早已抵达æµ�览器 —— hostã€�connectionã€�runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` å�—åŠ `+A -R · N file(s)` 页脚。
+
+Web 客户端忽略了它。write/edit 调用�到 `GenericToolCard`,其行从原始工具�数推导,详情��把 result 的 content block 摊平进一个 ``。`diffs` 载� —— result 的全部�义 —— 被丢弃,于是一次文件改动读起��是一行确认�看�到任何改动。
+
+这是把 [terminal �片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支��一�:那次改动让 Web 客户端�为 `terminal` 渲染�图的消费者;这次让它�为 `diff` 渲染�图的消费者,�用�一套四层结构。
+
+## Decision
+
+`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内è�” diff 表é�¢ï¼Œwrite/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染æ„�图:chat 工具行的行体和详情é�¢æ�¿çš„ Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转æˆ�组件 props çš„åœ°æ–¹ï¼Œå› æ¤ä¸¤ä¸ªæ¸²æŸ“点ä¸�会对一次改动产生分æ§ã€‚当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本ä¸�认识的 `card` 值,以å�Šå·²ç»“算调用的 result view 是 generic 的情况(write/edit çš„æ‰§è¡Œé”™è¯¯æ£æ˜¯è¿™æ ·ç•™åœ¨é€šç”¨è·¯å¾„上的)。调用结算å�Ž result 侧是æ�ƒå¨�:已应用的 hunk 替æ�¢ä»…从å�‚数推导的 call æ—¶ diff。分页窗å�£ä¸¢å¼ƒäº† call 头也ä»�èƒ½æ¸²æŸ“ï¼Œå› ä¸º result view æ�ºå¸¦å®Œæ•´æ”¹åŠ¨ã€‚
+
+组件的契约�循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个�端读起�是�一形�:
+
+- **æ¯�个文件一个路径头。** 新文件开å�¯ä¸€ä¸ªç²—体路径头;å�Œæ–‡ä»¶çš„第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap å¼€å�¯ï¼Œè€Œé�žé‡�å¤�路径。`N file(s)` 页脚在两个å‰�端都统计**去é‡�å�Žçš„路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去é‡�è·¯å¾„è®¡æ•°ï¼Œå› æ¤å�Œæ–‡ä»¶ä¸¤ä¸ª hunk 在两端都读作 `1 file`。
+- **改动用 diff 自身的颜色。** åˆ é™¤è¡Œæ˜¯ error token 上的 `- `,新增行是 success token 上的 `+ `,在横å�‘滚动的盒å�里以 `white-space: pre` é€�å—绘制 —— æº�ç �行é� 缩进阅读,所以滚动而ä¸�折行。新建(`oldText: null`ï¼‰æ²¡æœ‰åˆ é™¤ä¾§ã€‚
+- **高度上é™�带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` ä¸ªå¤´éƒ¨è¡ŒåŠ å‰©ä½™å°¾éƒ¨è¡Œï¼Œä¸é—´ä¸€ä¸ªæŒ‰é’®æŠ¥å‘Šéš�è—�行数。分割算术与 `TerminalBlock` å’Œ TUI 的折å� å�¡ç‰‡ä¸€è‡´ï¼Œå› æ¤é•¿ diff 的头尾切片在两个å‰�端一致。
+- **行终æ¢ç¬¦ã€‚** æ¯�一侧的内容按 `TerminalBlock` 的终æ¢ç¬¦è§„则在 `\n` ä¸Šåˆ‡åˆ†ï¼šç©ºæ–‡æœ¬æ˜¯é›¶è¡Œï¼ˆæ•´æ–‡ä»¶åˆ é™¤çš„ `newText`ã€�新建缺失的 `oldText` 侧),å�•个结尾æ�¢è¡Œç»ˆæ¢å…¶æœ€å�Žä¸€è¡Œè€Œé�žæ–°å¢žä¸€æ�¡å¹»å½±ç©ºè¡Œï¼Œå†…部空行ä¿�留。本 PR 把å�Œä¸€è§„则应用到了 TUI diff åˆ†æ”¯ï¼Œå› æ¤å¯¹äºŽçœŸå®ž write/edit 调用æ�ºå¸¦çš„以æ�¢è¡Œç»“尾的内容,两个å‰�端的 `+A -R` 页脚计数一致。
+- **页脚与å¤�制。** 暗色 `â”” +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/åˆ é™¤è¡Œæ•°ï¼Œä¸Ž TUI 页脚绘制的æ¯�侧计数相å�Œã€‚å¤�制控件å¤�制带å‰�缀的 diff 文本(路径头ã€�`- `/`+ ` 行ã€�`⋯` gap),使多文件å¤�制ä¿�æŒ�å�¯å½’属。
+
+å‡ ä½•ã€�圆角ã€�å—体镜åƒ� `CodeBlock`/`TerminalBlock`,使 diff å�¡ç‰‡ã€�terminal å�¡ç‰‡ã€�代ç �å�—读起æ�¥æ˜¯ä¸€å®¶ï¼›`white-space: pre` åŠ æ¨ªå�‘滚动是刻æ„�的分æ§ã€‚å¤�制控件浮在å�¡ç‰‡å�³ä¸Šè§’,而é�žå� æ�®è‡ªå·±çš„ banner è¡Œï¼Œå› ä¸ºå�ªæ”¾ä¸€ä¸ªå¤�制按钮的 banner 会在第一行 diff 上方画出一æ�¡ç©ºå¸¦ —— TUI çš„ diff å�¡ç‰‡ä¹Ÿæ²¡æœ‰ banner,å�ªæœ‰é¡µè„šã€‚
+
+chat 行把 diff 常驻渲染在路径链接摘è¦�之下,上é™� `CHAT_DIFF_MAX_LINES`(8),对应é�¢æ�¿çš„ 16 —— 与 [terminal å�¡ç‰‡](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内è�”输出决ç–ã€�以å�Šæµ�内表é�¢å¯¹å�•调阅读表é�¢çš„å�Œä¸€åˆ’分一致。write/edit 行是å�•文件的,所以它的摘è¦�既是å�¯æ‰“开的路径链接,其 diff å�¡ç‰‡å�ˆå±•开;两者共å˜ï¼Œå› 为å�¡ç‰‡ä¸�是路径的å�‚数体。
+
+## Alternatives considered
+
+**并排(å�Œæ �)diff。** owner ç›®å‰�æ‹’ç»�:它更密但ä¸�适å�ˆç‹çª„çš„ chat è¡Œï¼Œç›®æ ‡æ˜¯ä¸Ž TUI å�•æ �统一形å¼�对é½�。详情é�¢æ�¿é‡Œçš„å�Œæ �模å¼�是å�Žç»çš„ props 改动,ä¸�是é‡�设计。
+
+**git å¼�行å�·æ§½ã€‚** `FileDiff` 契约å�ªæ�ºå¸¦ `{ path, oldText, newText }` —— `structuredPatch` çš„ hunk 起始行在 `diff.ts` 里被丢弃,所以没有行å�·æŠµè¾¾å®¢æˆ·ç«¯ã€‚渲染行å�·æ§½éœ€è¦�å�Žç«¯å¥‘约改动(æ�ºå¸¦ `oldStart`/`newStart`)并å�Œæ¥å�‡çº§ TUI 以ä¿�æŒ�一致;推迟,使本 PR ä¿�æŒ�为对既有契约的纯 Web 消费。
+
+**å¤�用 `CodeBlock`。** å› ä¸Ž terminal å�¡ç‰‡ç›¸å�Œçš„ç�†ç”±æ‹’ç»�:`CodeBlock` 会折行,且没有æ¯�行 `+`/`-` 角色ã€�没有路径头ã€�æ²¡æœ‰é¡µè„šã€‚ä¸¤è€…å…±äº«å‡ ä½•ä¸Žå—体 token,那是唯一一处一个实现对两者都æ£ç¡®çš„部分。
+
+## Consequences
+
+`DiffBlock` å�ªè¯» diff view çš„å—æ®µï¼Œå› æ¤å®ƒæ˜¯æ¸²æŸ“æ„�图所æ�ºå¸¦å†…容的纯函数 —— 与产出该视图的 presenter ä¸€æ ·å›žæ”¾å®‰å…¨ã€‚æ²¡æœ‰ diff 能力的 UI ä»�得到 bridge 的通用回退;工具的 result 形状没有任何改å�˜ã€‚æ— æ–°å¢žè¿�行时ä¾�赖:ä¸�å�ŒäºŽ terminal å�¡ç‰‡çš„ `anser`,diff ä¸�需è¦�è§£æž�器。
+
+`DiffBlock` çš„å¤šæ–‡ä»¶æ”¯è·¯ï¼ˆä¸€å¼ å�¡ã€�多个路径头)今天没有生产者:`write`/`edit` æ¯�次调用å�„改一个文件,所以真实å�¡ç‰‡æ˜¾ç¤ºä¸€ä¸ªæ–‡ä»¶å¸¦ä¸€ä¸ªæˆ–多个 hunk。该支路为将æ�¥çš„多文件改动工具而构建并测试,ä¸�是为当å‰�消费者。
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` é’‰ä½�组件:新建支路(å�ªæœ‰æ–°å¢žã€�æ— åˆ é™¤ä¾§ï¼‰ã€�ç¼–è¾‘æ”¯è·¯ï¼ˆåˆ é™¤åœ¨æ–°å¢žä¹‹ä¸Šï¼‰ã€�å�Œæ–‡ä»¶ `⋯` gap 对比新文件自己的头ã€�空 diffs çš„ null 渲染ã€�页脚计数å�Šå…¶å�•å¤�æ•°ã€�头尾上é™�å�Šå…¶ `aria-expanded` 切æ�¢ã€�以å�Šå¤�制控件在接å�—与拒ç»�两æ�¡å‰ªè´´æ�¿è·¯å¾„上æ–言带å‰�缀的 diff 文本。Per-file 100%。
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` é’‰ä½�æ¯�个渲染点的接线:`diffCardModel` 的派生å�Šå…¶æ¯�个 null 支路ã€�result hunk 替æ�¢ call æ—¶ diffã€�窗å�£æˆªæ–çš„ call ä»�从 result 渲染ã€�chat 行的 diff 体ã€�`FileMutationRow` 的常驻å�¡ç‰‡å�Šå…¶è·¯å¾„链接ç»� host 以 cwd è§£æž�打开ã€�其在 `write` 与 `edit` 下的注册ã€�以å�Šé�¢æ�¿çš„ Output 区。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)æ�ºå¸¦ä¸‰ä¸ª diff turn,使 `?fixture` æœ�务与 per-package 接线测试套件在两个渲染点演练全部三个支路:å�• hunk 编辑(turn 62,keyed `FileMutationRow`)ã€�新建/写入(turn 63)ã€�多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是å�¯åŠ¨è£…é…� smoke,å�ªæ–言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它ä¸�带 diff 行为æ–言,那由接线套件负责。
+
+## Related
+
+- [Web terminal å�¡ç‰‡](2026-07-28-web-terminal-card.md) —— `terminal` 支路的å�Œä¸€å¥—四层结构;本 note å¤�用其内è�”输出决ç–与头尾上é™�算术。
+- [å·¥å…·è°ƒç”¨å‘ˆçŽ°çš„æ ‡ç¾åŒ– render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` æ ‡ç¾è¯�汇;Web 客户端现在也是 `diff` 支路的消费者。
+- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml
new file mode 100644
index 0000000000..7efb9e75d5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.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-07-30-web-read-card-frontend.md
+2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe
+2026-07-30-web-read-card-frontend.zh.md: b6314f21ba3eb2283788374b10c77ed22e26d16c
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
new file mode 100644
index 0000000000..f504cab770
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
@@ -0,0 +1,54 @@
+# Agent Note: Web read card frontend — the read tool's line window renders line-numbered and highlighted
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-web-read-card-frontend.zh.md)
+
+## Problem
+
+The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent card, `card: 'read'`, to `ToolResultView`: a settled read now carries `{ path, lines: [{ number, text }], totalLines, lang? }` onto the conversation snapshot as `resultView`. That data reaches the browser, but the Web client had no consumer for it. Every read row derived from args alone and the details panel flattened the result's content blocks into one ``, so a read showed as `N: text`-prefixed plain text with no gutter, no syntax highlighting, and no "showing N of M" affordance for a windowed read. The [web terminal card](2026-07-28-web-terminal-card.md) established the pattern for consuming a structured card; the read card follows it, result-side only.
+
+## Decision
+
+`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-conversation/src/client/contract/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
+
+**A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
+
+`readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.
+
+The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
+
+Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
+
+**Read-card grammars load lazily; only the boot three stay eager.** `highlight.ts` is a platform seed `ui-primitives` loads on every Web boot, and its warm-up unconditionally builds the shiki singleton. The read card's `langFromPath` hints span the full source/config/markup extension set (python, rust, yaml, html, …); registering all of them eagerly would add ~1.6 MB of grammar modules to the boot chunk and their synchronous init to every session, including sessions that never open a read card. So only the three grammars every session already renders — TypeScript, shell, JSON (the markdown-fence and `run_code` languages) — load at boot. Each read-card extension grammar sits behind a dynamic `import()` in `LAZY_GRAMMARS`, keyed by the grammar id its aliases resolve to. On the first `highlightLines`/`highlightToHtml` call for a lazy language, `ensureGrammar` starts the import (once) and returns not-ready, so the card renders plain that frame; when the import resolves it registers the grammar with `loadLanguageSync`, bumps a load counter, and notifies subscribers. `ReadBlock` and `CodeBlock` subscribe through `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)`, so the card re-renders with highlighting the moment the grammar is ready. An unknown/absent language still returns undefined synchronously (plain, never an error).
+
+**The empty-window copy control is hidden, matching `TerminalBlock`.** A successful read of an empty file returns `lines: []`, `totalLines: 0`, and `presentResult` still projects `card: 'read'`, so the empty-window branch is reachable — the read card is not, as an earlier draft assumed, unreachable for an empty result. `ReadBlock` therefore hides the copy control when `lines` is empty, exactly as `TerminalBlock` hides copy on empty output, so the button can never wipe the clipboard with an empty string.
+
+## Alternatives considered
+
+**Extend `CodeBlock` with an optional line-number gutter and `startLine`.** Rejected: it imposes a read-specific gutter, a windowed-count note, and a height cap on every markdown fence and `run_code` body that shares `CodeBlock`, for no benefit to those callers. The genuinely shared surface is the shiki grammar singleton, which both blocks reuse through `highlight.ts`; the chrome around it differs (a read has a gutter and a window note, a fence has neither), so a second small primitive is the correct split, exactly as `TerminalBlock` is a second primitive over the same tokens rather than a `CodeBlock` mode.
+
+**Reuse `highlightToHtml` and inject gutter numbers with CSS counters.** Rejected: the single-`` HTML shiki emits has no per-line boundary a gutter can hang a file line number off (a windowed read's numbers start above 1 and are not a simple CSS counter increment), and parsing the numbers back out of the HTML would be fragile. `codeToTokens` gives the per-line token structure directly.
+
+**Register all read-card grammars eagerly in the boot warm-up.** Rejected: it puts ~1.6 MB of grammar modules and their synchronous init on every Web boot for a card most sessions never open. The lazy path costs a single plain-first frame the first time a given language is read, then highlights on the grammar-load re-render; the boot cost is paid only for the three grammars every session already renders.
+
+## Consequences
+
+`ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged.
+
+A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/code-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form.
+
+## Testing
+
+`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
+
+`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
+
+## Related
+
+- [Read card backend](2026-07-30-web-read-card.md) — adds the `card: 'read'` result view this consumes; produces the `lines`/`totalLines`/`lang` this renders.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this follows: a `ui-primitives` block, a `contract/*-card-model.ts` derivation, a keyed row, and making `GenericToolCard`/`DetailsPanel` card-aware.
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and the shiki `highlight.ts` singleton this extends with a per-line token path.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `read` arm.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
new file mode 100644
index 0000000000..b6314f21ba
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
@@ -0,0 +1,54 @@
+# Agent Note: Web 读å�–å�¡ç‰‡å‰�端 —— 读å�–工具的行窗å�£ä»¥å¸¦è¡Œå�·ã€�è¯æ³•高亮的形å¼�渲染
+
+Status: implemented
+
+[English](2026-07-30-web-read-card-frontend.md) | 䏿–‡
+
+## Problem
+
+[读å�–å�Žç«¯](2026-07-30-web-read-card.md)ç»™ `ToolResultView` å¢žåŠ äº†ç¬¬å››ç§�渲染æ„�图å�¡ç‰‡ `card: 'read'`:一次已结算的读å�–现在会把 `{ path, lines: [{ number, text }], totalLines, lang? }` 作为 `resultView` 带到会è¯�快照上。这份数æ�®èƒ½åˆ°è¾¾æµ�览器,但 Web 客户端没有消费者。æ¯�个读å�–行都仅从å�‚数派生,详情é�¢æ�¿æŠŠç»“果的 content block 摊平进一个 ``,于是读å�–显示为带 `N: text` å‰�缀的纯文本,没有行å�·æ �ã€�æ²¡æœ‰è¯æ³•高亮,也没有窗å�£è¯»å�–çš„"显示 N / M"æ��示。[web 终端å�¡ç‰‡](2026-07-28-web-terminal-card.md)确立了消费一个结构化å�¡ç‰‡çš„æ¨¡å¼�;读å�–å�¡ç‰‡æ²¿ç”¨å®ƒï¼Œå�ªåœ¨ç»“果侧。
+
+## Decision
+
+`ReadBlock` 是一个 `ui-primitives` 组件,把一次读å�–结果渲染æˆ�带行å�·ã€�å�¯é€‰è¯æ³•高亮的文件视图,读å�–的两个 Web 渲染点都通过它消费读å�–渲染æ„�图:è�Šå¤©å·¥å…·è¡Œï¼ˆå¸¸é©»åœ¨æ‘˜è¦�行之下)与详情é�¢æ�¿çš„ Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转æˆ�组件 props 的唯一ä½�ç½®ï¼Œå› æ¤ä¸¤ä¸ªæ¸²æŸ“点ä¸�会产生分æ§ã€‚
+
+**新建一个 `ReadBlock` primitive,而ä¸�是扩展 `CodeBlock`。** `CodeBlock` å·²ç»�带è¯è¨€æ¨ªå¹…å’Œå¤�制控件å�š shiki 高亮,但读å�–视图需è¦�一个æ¯�行带该行自身文件行å�·çš„行å�·æ �,而 `CodeBlock` 把内容渲染为å�•个 `` æ ‘ã€�没有é€�行结构。给 `CodeBlock` åŠ ä¸€ä¸ªå�¯é€‰è¡Œå�·æ �会把读å�–专属的关切(窗å�£è¡Œå�·ã€�"显示 N / M"æ��示ã€�高度上é™�ï¼‰å¼ºåŠ ç»™å…±äº«è¯¥ç»„ä»¶çš„æ¯�个 markdown 代ç �å›´æ �å’Œæ¯�个 `run_code` 程åº�体。`ReadBlock` 转而å¤�用真æ£å…±äº«çš„部分:`markdown/highlight.ts` 里的 shiki è¯æ³•å�•例。那里新增的 `highlightLines(code, lang)` 把代ç �切æˆ� shiki 自己的é€�行 token 数组(`codeToTokens`),而ä¸�是 `highlightToHtml` 产出的å�• `` HTML,于是该 block 能æ¯�行放一个行å�·ã€�å�Œæ—¶ç”¨å�Œä¸€å¥— `--shiki-*` 自定义属性ã€�å�Œä¸€ä»½è¯æ³•白å��å�•给内容上色。高度上é™�å�Šå…¶å¤´/尾展开算法照抄自 `TerminalBlock`(`ceil(max/2)` è¡Œå¤´éƒ¨åŠ å‰©ä½™çš„å°¾éƒ¨ï¼‰ï¼Œå› æ¤é•¿è¯»å�–和长命令输出在å�Œä¸€å¤„折å� 。å¤�制控件写入窗å�£çš„原始文本(å�„行以æ�¢è¡Œæ‹¼æŽ¥ï¼‰ï¼Œç»�ä¸�å�«è¡Œå�·æ �或横幅。
+
+`readCardModel` å�ªåœ¨ç»“果侧,与å�Žç«¯å¯¹ç§°ï¼šä¸€æ¬¡è¯»å�–调用在 `execute` 返回å‰�ä¸�å¸¦ä»»ä½•å†…å®¹ï¼Œå› æ¤æŒ‚èµ·ä¸çš„调用ä¿�æŒ�为 `GenericCallView`(`kind: 'read'`),本函数对è¿�行ä¸çš„读å�–返回 null —— 该行ä¿�æŒ�其从å�‚数派生的摘è¦�,直到结果到达。它对结果视图ä¸�是读å�–å�¡ç‰‡çš„已结算调用也返回 null,包括本 UI 版本ä¸�认识的 `card` 值(它从线路到æ�¥ã€�ä¸�能被信任为一个已编译的å�˜ä½“)以å�Šè¯»å�–工具对错误结果自己的通用回退。å�¡ç‰‡æ¨ªå¹…æ ‡ç¾åœ¨å·¥å…·æ��ä¾› `title` æ—¶å�–它(契约的替æ�¢æ ‡é¢˜è§„则),å�¦åˆ™å�–相对于会è¯�工作区化简å�Žçš„æ–‡ä»¶è·¯å¾„ï¼Œä½¿å·¥ä½œåŒºæ ¹ä¸‹çš„ç»�对路径显示为与行摘è¦�相å�Œçš„çŸå½¢å¼�。该 model 把冻结的行数组å¤�制进 primitive è‡ªå·±çš„è¡Œå½¢çŠ¶ï¼Œå› æ¤å�¡ç‰‡ç»�ä¸�æŒ�有指å�‘è¿�行时快照缓å˜çš„引用。
+
+è�Šå¤©è¡ŒæŠŠå�¡ç‰‡**常驻**渲染在摘è¦�行之下,上é™� `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一å�Šï¼‰ï¼Œä¸Ž `BashRow` 对终端å�¡ç‰‡çš„å§¿æ€�相å�Œ —— block 的内部展开器让长读å�–ä¸�会å� æ�®æ•´ä¸ªæ¶ˆæ�¯æµ�。两个渲染点承载它:keyed `ReadRow`(在 `apply.ts` 里以 `read` é”®æ³¨å†Œï¼ŒåŠ è½½é¡ºåº�接ç¼�为 `inject: ['slots', 'conversation']`,与 bash æ ·ä¾‹å®Œå…¨ä¸€è‡´ï¼‰ï¼Œå…¶æ‘˜è¦�是作为å�¯æ‰“开的宿主链接的文件路径;以å�Š `GenericToolCard` 对没有自己 keyed 行的读å�–声明工具(例如归到 `read` å�˜ä½“çš„ `web_fetch`)的回退。详情é�¢æ�¿ä»¥ primitive 自己的全高上é™�(16)渲染å�Œä¸€å¼ å�¡ç‰‡ï¼Œå› 为é�¢æ�¿æ˜¯å�•次调用的阅读界é�¢ã€‚
+
+整行折å� /展开(把æ¯�个工具调用默认折å� )是一个å�•独的å�Žç»æ”¹åŠ¨ï¼Œå®ƒä¼šä¸€æ¬¡æ€§ç¿»è½¬æ¯�å¼ å¸¸é©»å�¡ç‰‡ï¼›æœ¬ note çš„å�¡ç‰‡æ˜¯å¸¸é©»çš„,与它æ—�边的终端å�¡ç‰‡ä¸€è‡´ã€‚
+
+**读å�–å�¡ç‰‡çš„è¯æ³•按需 lazy åŠ è½½ï¼Œå�ªæœ‰ boot 三ç§�ä¿�æŒ� eager。** `highlight.ts` 是 `ui-primitives` 在æ¯�次 Web å�¯åŠ¨éƒ½åŠ è½½çš„å¹³å�° seed,其预çƒä¼šæ— æ�¡ä»¶æž„建 shiki å�•例。读å�–å�¡ç‰‡çš„ `langFromPath` æ��示覆盖完整的æº�ç �/é…�ç½®/æ ‡è®°æ‰©å±•é›†ï¼ˆpythonã€�rustã€�yamlã€�html……);把它们全部 eager 注册会给å�¯åЍ chunk å¢žåŠ çº¦ 1.6 MB çš„è¯æ³•模å�—ã€�并把它们的å�Œæ¥åˆ�始化摊给æ¯�个会è¯�,包括从ä¸�打开读å�–å�¡ç‰‡çš„会è¯�ã€‚å› æ¤å�ªæœ‰æ¯�个会è¯�本就渲染的三ç§�è¯æ³• —— TypeScriptã€�shellã€�JSON(markdown å›´æ �与 `run_code` è¯è¨€ï¼‰â€”— 在 boot æ—¶åŠ è½½ã€‚æ¯�ç§�读å�–å�¡ç‰‡æ‰©å±•è¯æ³•置于 `LAZY_GRAMMARS` ä¸ä¸€ä¸ªåЍæ€� `import()` 之å�Žï¼Œä»¥å…¶åˆ«å��è§£æž�åˆ°çš„è¯æ³• id 为键。对æŸ�个 lazy è¯è¨€é¦–次调用 `highlightLines`/`highlightToHtml` 时,`ensureGrammar` å�¯åЍ import(仅一次)并返回未就绪,于是å�¡ç‰‡è¯¥å¸§æ¸²æŸ“纯文本;import è§£æž�å�Žç”¨ `loadLanguageSync` æ³¨å†Œè¯¥è¯æ³•ã€�é€’å¢žä¸€ä¸ªåŠ è½½è®¡æ•°ã€�并通知订阅者。`ReadBlock` 与 `CodeBlock` 通过 `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)` è®¢é˜…ï¼Œå› æ¤è¯æ³•就绪的那一刻å�¡ç‰‡å°±é‡�渲染带上高亮。未知/缺çœ�è¯è¨€ä»�å�Œæ¥è¿”回 undefined(纯文本,ç»�ä¸�报错)。
+
+**空窗å�£çš„å¤�制控件被éš�è—�,与 `TerminalBlock` 对é½�。** æˆ�功读å�–一个空文件会返回 `lines: []`ã€�`totalLines: 0`,且 `presentResult` ä»�投出 `card: 'read'`ï¼Œå› æ¤ç©ºçª—å�£åˆ†æ”¯æ˜¯å�¯è¾¾çš„ —— 读å�–å�¡ç‰‡å¹¶é�žå¦‚æ—©å‰�è�‰ç¨¿æ‰€å�‡è®¾çš„对空结果ä¸�å�¯è¾¾ã€‚æ•… `ReadBlock` 在 `lines` 为空时éš�è—�å¤�制控件,æ£å¦‚ `TerminalBlock` 对空输出éš�è—�å¤�制,使按钮ç»�ä¸�会用空å—符串清空剪贴æ�¿ã€‚
+
+## Alternatives considered
+
+**ç»™ `CodeBlock` åŠ ä¸€ä¸ªå�¯é€‰è¡Œå�·æ �å’Œ `startLine`。** æ‹’ç»�:这会把读å�–专属的行å�·æ �ã€�窗å�£è®¡æ•°æ��示和高度上é™�å¼ºåŠ ç»™å…±äº« `CodeBlock` çš„æ¯�个 markdown å›´æ �å’Œ `run_code` 程åº�ä½“ï¼Œå¯¹é‚£äº›è°ƒç”¨è€…æ¯«æ— å¥½å¤„ã€‚çœŸæ£å…±äº«çš„界é�¢æ˜¯ shiki è¯æ³•å�•例,两个 block 都通过 `highlight.ts` å¤�用它;围绕它的外壳å�„ä¸�相å�Œï¼ˆè¯»å�–有行å�·æ �和窗å�£æ��示,围æ �ä¸¤è€…éƒ½æ²¡æœ‰ï¼‰ï¼Œå› æ¤ç¬¬äºŒä¸ªå°� primitive 是æ£ç¡®çš„切分 —— æ£å¦‚ `TerminalBlock` 是基于å�Œä¸€å¥— token 的第二个 primitive,而ä¸�是 `CodeBlock` 的一ç§�模å¼�。
+
+**å¤�用 `highlightToHtml`,用 CSS counter 注入行å�·ã€‚** æ‹’ç»�:shiki 产出的å�• `` HTML 没有å�¯ä¾›è¡Œå�·æ �挂上文件行å�·çš„é€�行边界(窗å�£è¯»å�–的行å�·ä»Žå¤§äºŽ 1 处开始,ä¸�是简å�•çš„ CSS counter 自增),而从 HTML 里把行å�·è§£æž�回æ�¥å�ˆå¾ˆè„†å¼±ã€‚`codeToTokens` 直接给出é€�行 token 结构。
+
+**在 boot 预çƒé‡Œ eager 注册所有读å�–å�¡ç‰‡è¯æ³•。** æ‹’ç»�:这会给æ¯�次 Web å�¯åŠ¨æ‘Šä¸Šçº¦ 1.6 MB è¯æ³•模å�—å�Šå…¶å�Œæ¥åˆ�始化,å�ªä¸ºä¸€å¼ 多数会è¯�从ä¸�打开的å�¡ç‰‡ã€‚lazy 路径的代价是æŸ�个è¯è¨€é¦–次被读å�–时的一帧纯文本,éš�å�Žåœ¨è¯æ³•åŠ è½½çš„é‡�渲染里高亮;boot 代价å�ªä¸ºæ¯�个会è¯�本就渲染的三ç§�è¯æ³•付出。
+
+## Consequences
+
+`ui-primitives` å¢žåŠ `ReadBlock` å’Œ `highlightLines`;没有新的è¿�行时ä¾�赖(shiki å·²å› `CodeBlock` å˜åœ¨ï¼‰ã€‚`ReadBlock` å�ªè¯»å�–读å�–è§†å›¾çš„å—æ®µï¼Œå› æ¤ä¿�æŒ�为渲染æ„�图所承载内容的纯函数 —— æ— ä¼šè¯�查询,与产出该视图的 presenter ä¸€æ ·å�¯å®‰å…¨å›žæ”¾ã€‚没有读å�–能力的 UI ä»�通过通用å�¡ç‰‡æ‹¿åˆ°å�Žç«¯çš„ `content` 回退(剥掉外壳的文本),ä¿�æŒ�ä¸�å�˜ã€‚
+
+Web è�Šå¤©é‡Œçš„读å�–行现在常驻承载文件内容,是相对纯摘è¦�行的一次刻æ„�çš„å¯†åº¦å¢žåŠ ï¼Œå�—è�Šå¤©ä¸Šé™�约æ�Ÿã€‚`run_code` å�æ´¾å�‘在已å�‘布的线路上到ä¸�了读å�–å�¡ç‰‡ï¼Œä¸ŽåµŒå¥— bash 调用到ä¸�了终端å�¡ç‰‡å�Œå› :`session.ts` 把 `tool/code-dispatch(-start)` 折å� 为 `resultView: null`ï¼Œå› æ¤åµŒå¥—读å�–ä¿�æŒ�通用的摊平形å¼�。
+
+## Testing
+
+`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` çš„é€�行 css-variables è¿�行ã€�它对尾部终æ¢è¡Œçš„丢弃与真æ£ç©ºç™½æœ«è¡Œçš„æƒ…å½¢ã€�它对未知/缺çœ�è¯è¨€è¿”回 `undefined`ã€�以å�Šå®ƒçš„ lazy 路径(lazy è¯æ³•首次触碰返回纯文本,import 注册且订阅者触å�‘å�Žå†�高亮);还有 `ReadBlock` 的带行å�·è¡Œä¿�留文件自身编å�·ã€�高亮与纯文本两æ�¡å†…容分支ã€�æ¨ªå¹…ï¼ˆæ ‡ç¾ã€�è¯è¨€ã€�仅当读å�–是窗å�£æ—¶çš„计数æ��示)ã€�头/尾高度上é™�å�Šå…¶ `aria-expanded` 切æ�¢ã€�å¤�制控件在接å�—与拒ç»�两æ�¡å‰ªè´´æ�¿è·¯å¾„上写入窗å�£åŽŸå§‹æ–‡æœ¬ã€�以å�Šç©ºçª—å�£åˆ†æ”¯éš�è—�å¤�制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,å�«å®ƒå¯¹æ¯�ç§�读å�–å�¡ç‰‡è¯æ³•çš„ lazy 路径(æ¯�个动æ€� import thunk å�„触碰一次)。`ReadBlock.tsx`ã€�`highlight.ts`(å�Š `CodeBlock.tsx`)在这两个 spec 上å�‡ä¿�æŒ�æ¯�文件 100% 覆盖。
+
+`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定æ¯�个渲染点的接线:`readCardModel` 的派生与æ¯�æ�¡ null 分支(è¿�行ä¸è¯»å�–ã€�æ— è§†å›¾ã€�通用视图ã€�未知å�¡ç‰‡ï¼‰ã€�ç»“æžœæ ‡é¢˜æ›¿æ�¢åŒ–简å�Žçš„路径ã€�路径相对工作区的化简ã€�冻结行数组的å¤�制而é�žåˆ«å��ï¼›`GenericToolCard` 回退ä¸ä¸Ž keyed `ReadRow` ä¸çš„常驻å�¡ç‰‡ï¼ˆå¤–åŠ å…¶è·¯å¾„é“¾æŽ¥æ‰“å¼€å®¿ä¸»ã€�å…¶ running/error/stopped 状æ€�ã€�以å�Šå…¶ `read` 键注册);还有é�¢æ�¿ Output 区段以全高渲染读å�–å�¡ç‰‡å�Œæ—¶ä¿�ç•™ JSON Input 区段,å�«è¿�行ä¸è¯»å�–å� ä½�与é�žè¯»å�–摊平 pre 两æ�¡åˆ†æ”¯ã€‚该文件ä½�于覆盖 `exclude` 列表(`ui-conversation/src/*`ï¼‰ï¼Œå› æ¤ä¸�承å�—门槛压力。
+
+fixture(`packages/client/connection/src/client/fixture.ts`ï¼‰å¢žåŠ turn 66,一次 `read` 调用,其结果视图是窗å�£è¯»å�–(行å�·ä»Žæ–‡ä»¶è¡Œ 41 èµ·ã€�`totalLines` 180ã€�`ts` æ��示),使内置å�¯åŠ¨å¿«ç…§å’Œå®žæ—¶ `?fixture` æœ�务器展示带行å�·ã€�高亮和计数æ��示的读å�–å�¡ç‰‡ã€‚它命å��为 `read` 以驱动 keyed `ReadRow`。turn 64 çš„ `run_code` æ ·ä¾‹ä¸çš„嵌套读å�–å�æ´¾å�‘å¹¶ä¸�驱动渲染点回退读å�–å�¡ç‰‡ï¼š`session.ts` 把它们折å� 为 `resultView: null`ï¼Œå› æ¤å®ƒä»¬å�ªè¦†ç›–回退行的通用行形状,而é�žå›žé€€è¡Œå†…的读å�–å�¡ç‰‡ï¼›å›žé€€è¡Œè¯»å�–å�¡ç‰‡ç”± `read-card.spec.tsx` çš„ `web_fetch` 用例钉ä½�。turn 66 排在 todo turn(现为 67)之å‰�ï¼Œä¸Žç»ˆç«¯æ ·ä¾‹å�Œå› :常驻计划在下一次 `turn/start` 退场。
+
+## Related
+
+- [读å�–å�¡ç‰‡å�Žç«¯](2026-07-30-web-read-card.md) —— å¢žåŠ æœ¬æ–‡æ¶ˆè´¹çš„ `card: 'read'` 结果视图;产出本文渲染的 `lines`/`totalLines`/`lang`。
+- [Web 终端�片](2026-07-28-web-terminal-card.md) —— 本文�循的先例:一个 `ui-primitives` block�一个 `contract/*-card-model.ts` 派生�一个 keyed 行,以�让 `GenericToolCard`/`DetailsPanel` 感知�片。
+- [Web å®¢æˆ·ç«¯è¯æ³•高亮](../process/2026-07-26-web-syntax-highlighting-shiki.md) —— 拥有 `CodeBlock` 与 shiki `highlight.ts` å�•例,本文以é€�行 token 路径扩展它。
+- [å·¥å…·è°ƒç”¨å‘ˆçŽ°çš„æ ‡ç¾å¼�渲染æ„�图è�”å�ˆ](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` æ ‡ç¾è¯�汇表;Web 客户端现在是 `read` 分支的完整消费者。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
new file mode 100644
index 0000000000..abefa88679
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card.md
+2026-07-30-web-read-card.md: 1fb3d61a113d26f6daf023fc791f3638055b5be0
+2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
new file mode 100644
index 0000000000..1fb3d61a11
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
@@ -0,0 +1,49 @@
+# Agent Note: Read card — the read tool's structured line window reaches the client
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-web-read-card.zh.md)
+
+## Problem
+
+The `read` tool returns a canonical output object `{ path, offset, lines: [{ number, text }], totalLines }`, but its presentation collapsed that structure. `presentCall` declared a `GenericCallView` (`kind: 'read'`, a follow-along location) and `presentResult` returned a `GenericResultView` whose only content was the model-facing text with its `…file…` envelope stripped. A UI receiving that view saw one flattened text block: the line numbers were baked into the text as `N: ` prefixes, the file's language was unknown, and `totalLines` was gone. There was no way for a capable client to render a read the way it renders a diff — a line-numbered, syntax-highlighted code view with the line-number gutter separate from the content.
+
+The structured data cannot be recovered downstream. A tool result on the wire carries only the model-facing `ContentBlock[]` (the rendered text) plus an opaque `meta`; the canonical output object stays in the tool and never reaches the client or the session log. So a client that wants the line array, the total, and a language hint cannot parse them back out of the `N: text` text — the tool has to project them onto a channel that persists.
+
+## Decision
+
+Add a fourth `card` tag, `read`, to the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) — result-side only. `ToolResultView` gains `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`; `ReadFileLine { number; text }` is the shared line unit. `ToolCallView` is untouched: the pending state stays a `GenericCallView` (`kind: 'read'`) because a call carries no file content until `execute` returns, so there is nothing structured to show at call time. This diverges from the bash terminal card, which tags both sides — a terminal call already carries its command and cwd at call time, a read call carries neither content nor total, so tagging the call side would add an empty variant.
+
+The read tool projects the structured window through `output.presentationMeta`, the same persisted channel write/edit use for their applied-diff hunks ([canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). `presentationMeta` runs once for a top-level surface call, returns `{ path, offset, lines, totalLines, lang? }` as JSON the session validates and stores on the result's `meta`, and `presentResult` narrows that meta back into the `ReadResultView` on both live and replay paths. `offset` (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty `lines` array with a positive `totalLines`; without the persisted `offset` a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the `N: text` text is lossy and fragile against the truncation footer.
+
+`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `//` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability, including the current TUI, renders the file text through the generic/default card arm exactly as before. The TUI's `renderBody` switch (`packages/ui/tui/src/components/transcript.ts`) is not `assertNever`-exhaustive: `terminal` and `diff` have arms and everything else falls through to the generic arm, which reads `view.content`. That default arm alone was not enough: `render()` sets `genericContent` — and with it the dim-Markdown `dimBody` treatment — on a separate gate that was `card === 'generic'` only, so a `read` card would have kept the text but lost its dim styling. The gate now admits `card: 'read'` too, taking `content` down the same dim-Markdown path, so a read renders in the TUI exactly as it did before the read card existed. Beyond that one gate the TUI needs no read-specific code.
+
+### Language hint derivation
+
+`langFromPath` (in `read-render.ts`) maps a file extension to a syntax-highlighting language id through a small fixed table (`LANG_BY_EXTENSION`) covering common source, config, and markup extensions. It reads the extension after the last path segment and last dot, is case-insensitive, and returns `undefined` for a dotfile (`.gitignore`), an extensionless name (`/etc/hosts`), a trailing dot, and any unknown extension — the card then omits `lang` and a UI renders plain text. The table is not a tunable: it is a display hint a UI may ignore, not a deployment-varying choice, and an unknown extension degrades to plain text rather than failing. It is deliberately small rather than an exhaustive language registry; extending it is a one-line table addition.
+
+## Alternatives considered
+
+**Re-parse the `N: text` model-facing text in `presentResult`.** Rejected: the structured line array would have to be reconstructed by splitting each line on the first `: `, which is ambiguous (a line whose own text contains `: `), loses the exact `totalLines` (the footer only states it in some branches), and breaks the moment the render format changes. `presentationMeta` carries the already-structured data with no re-parse.
+
+**Tag the call side too (`ReadCallView`), mirroring the terminal card's both-sides symmetry.** Rejected: a read call has no content, no line array, and no total until it executes — a call-side read card would be an empty variant duplicating what `GenericCallView` (`kind: 'read'`, follow-along location) already expresses. The terminal card tags both sides because a terminal call genuinely carries call-time data (command, cwd); a read call does not.
+
+**Put the structured window in a new service or a side channel instead of `meta`.** Rejected: `meta` is the established persisted presentation channel (write/edit's applied diffs ride it), it replays for free with the session log, and it needs no new plumbing. A service would reinvent persistence and replay that the event log already provides.
+
+**A merge-extensible union instead of a closed tag.** Rejected for the same reason the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) closed: a new card needs consuming code to render it, so a variant a consumer silently drops is worse than a compile error. Adding `read` to the closed union is the sanctioned way to extend it — each consumer that switches on `card` keeps compiling because the new member falls through its generic default, and a consumer that wants the rich view adds its own arm.
+
+## Consequences
+
+`ToolResultView` has a fourth member. Every consumer that switches on `card` keeps compiling: the TUI and the current Web client route an unknown card to their generic path, and the read card carries `content` so that path shows the file text. The Web frontend that renders the line-numbered, syntax-highlighted view from `lines`/`lang`/`totalLines` is a separate follow-up PR; this PR is the backend that makes the data reachable. Until that lands, a read renders exactly as it did before (the generic text card) everywhere.
+
+The read tool now computes `presentationMeta` for every top-level read, a small per-call projection (a `lines.map` and one `langFromPath` call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured.
+
+## Testing
+
+`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. The `apps/cli` `parallel-file-reads` terminal golden (`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`) pins exactly that: a real replay executes the read tool, renders it through the new `card: 'read'` gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `read` result arm.
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — owns the `presentationMeta` persisted channel this projects the read window onto.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent for a client consuming a structured card; the read card follows the same producer pattern, result-side only.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
new file mode 100644
index 0000000000..946bcca95b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Read card — the read tool's structured line window reaches the client
+
+Status: implemented
+
+[English](2026-07-30-web-read-card.md) | 䏿–‡
+
+## Problem
+
+`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView`(`kind: 'read'`,一个跟éš�定ä½�),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `…file…` ä¿¡å°�å�Žçš„é�¢å�‘模型文本。收到该视图的 UI å�ªçœ‹åˆ°ä¸€ä¸ªåŽ‹å¹³çš„æ–‡æœ¬å�—:行å�·ä»¥ `N: ` å‰�缀烘焙进文本ã€�文件è¯è¨€æœªçŸ¥ã€�`totalLines` 丢失。capable å®¢æˆ·ç«¯æ— æ³•åƒ�渲染 diff é‚£æ ·æ¸²æŸ“ä¸€æ¬¡ read——å�³å¸¦è¡Œå�·ã€�è¯æ³•高亮ã€�行å�·æ§½ä¸Žå†…容分离的代ç �视图。
+
+结构化数æ�®åœ¨ä¸‹æ¸¸æ— 法æ�¢å¤�。线上(wire)的工具结果å�ªæ�ºå¸¦é�¢å�‘模型的 `ContentBlock[]`ï¼ˆå·²æ¸²æŸ“æ–‡æœ¬ï¼‰åŠ ä¸Šä¸€ä¸ªä¸�é€�明的 `meta`;规范化输出对象留在工具内,从ä¸�到达客户端或会è¯�æ—¥å¿—ã€‚å› æ¤æƒ³è¦�行数组ã€�总数和è¯è¨€æ��ç¤ºçš„å®¢æˆ·ç«¯æ— æ³•ä»Ž `N: text` 文本里解æž�回它们——工具必须把它们投影到一个会æŒ�久化的通é�“上。
+
+## Decision
+
+ç»™[渲染æ„�图 union](../architecture/2026-07-02-tool-render-intent-union.md) 新增第四个 `card` æ ‡ç¾ `read`——仅在结果侧。`ToolResultView` å¢žåŠ `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`ï¼›`ReadFileLine { number; text }` 是共享的行å�•元。`ToolCallView` ä¸�动:待定状æ€�ä»�是 `GenericCallView`(`kind: 'read'`ï¼‰ï¼Œå› ä¸ºä¸€æ¬¡è°ƒç”¨åœ¨ `execute` 返回å‰�ä¸�æ�ºå¸¦æ–‡ä»¶å†…容,调用时没有å�¯å±•示的结构。这与 bash 终端 card ä¸�å�Œâ€”—终端 card ä¸¤ä¾§éƒ½æ‰“æ ‡ç¾ï¼Œå› 为终端调用在调用时已æ�ºå¸¦å‘½ä»¤å’Œ cwd,而 read è°ƒç”¨æ—¢æ— å†…å®¹ä¹Ÿæ— æ€»æ•°ï¼Œç»™è°ƒç”¨ä¾§æ‰“æ ‡ç¾å�ªä¼šæ–°å¢žä¸€ä¸ªç©ºå�˜ä½“。
+
+read 工具通过 `output.presentationMeta` 投影结构化窗å�£ï¼Œè¿™ä¸Ž write/edit 用æ�¥æŠ•影其应用 diff hunk çš„æŒ�久化通é�“相å�Œï¼ˆ[规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用è¿�行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会è¯�æ ¡éªŒå¹¶å˜å‚¨åœ¨ç»“æžœ `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。`offset`(窗å�£è¯·æ±‚çš„ 1-based 起始行)一并æ�ºå¸¦ï¼Œæ˜¯å› 为当å—节上é™�低于首个选ä¸è¡Œæ—¶ï¼Œçª—å�£ä¼šè¿”回空的 `lines` 数组而 `totalLines` 为æ£ï¼›æ²¡æœ‰æŒ�久化的 `offset`,这类窗å�£çš„回放 card å°±æ— æ³•æŠ¥å‘Šå®ƒä»Žå“ªè¡Œå¼€å§‹ã€�或ç»è¯»åº”从哪行继ç»ï¼Œè€Œæœ«è¡ŒæŽ¨æ–与文本é‡�è§£æž�两ç§�兜底都有æ�Ÿã€‚没有这个通é�“ï¼Œè¡Œæ•°ç»„å’Œæ€»æ•°å°±æ— æ³•è§¦å�Šï¼šåŽŸå§‹è¾“å‡ºå¯¹è±¡ä¸�在线上,而é‡�æ–°è§£æž� `N: text` 文本既有æ�Ÿå�ˆå¯¹æˆªæ–脚注脆弱。
+
+`presentResult` 在以下情况返回 `undefined`——å�³ generic 回退:meta 缺失或畸形(`readMetaFromMeta` é˜²å¾¡æ€§æ”¶çª„å®ƒï¼Œå› æ¤å›žæ”¾æ—§çš„已记录结果永ä¸�抛错)ã€�结果是错误ã€�以å�Šå�•个文本å�—ä¸�是 read ä¿¡å°�。本 card 出现之å‰�记录的结果——信å°�å�ˆæ³•ä½†æ— æŒ�久化 `meta`——有æ„�èµ°å�Œä¸€æ�¡ `undefined` 路径:客户端回退到原始 `result.content`ï¼Œå› æ¤æ˜¾ç¤ºå¸¦ `//` ä¿¡å°�的原文,而é�žæ—§å±•示器返回的剥信å°� generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接å�—çš„é™�级:拒ç»�æ—§çš„ç£�ç›˜æ ¼å¼�,而é�žåŠ ä¸€ä¸ªå‰¥ä¿¡å°�的兼容分支——本 PR å·²é‡�录全部已å�‘布 fixtures,且 session æ ¼å¼�ä¸�承诺å�‘å�Žå…¼å®¹ã€‚在æˆ�功路径上,`presentResult` åœ¨ç»“æž„åŒ–å—æ®µä¹‹å¤–æ�ºå¸¦ `content`(剥信å°�å�Žçš„æ–‡æœ¬ï¼‰ï¼Œå› æ¤ä¸�具备 read 能力的 UI(包括当å‰�çš„ TUI)通过 generic/default card 分支渲染文件文本,与之å‰�完全一致。TUI çš„ `renderBody` switch(`packages/ui/tui/src/components/transcript.ts`)ä¸�是 `assertNever` 穷尽的:`terminal` å’Œ `diff` 有分支,其余都è�½å…¥ generic 分支,该分支读å�– `view.content`。仅有该默认分支还ä¸�够:`render()` 在一个独立门控上设置 `genericContent`(连å�Œ dim-Markdown çš„ `dimBody` 处ç�†ï¼‰ï¼Œè¯¥é—¨æŽ§åŽŸå…ˆå�ªåˆ¤ `card === 'generic'`ï¼Œå› æ¤ `read` card 虽ä¿�留文本å�´ä¼šä¸¢å¤± dim æ ·å¼�。现在该门控也接纳 `card: 'read'`,让 `content` èµ°å�Œä¸€æ�¡ dim-Markdown è·¯å¾„ï¼Œå› æ¤ read 在 TUI ä¸çš„æ¸²æŸ“与 read card 出现之å‰�完全一致。除这一处门控外,TUI æ— éœ€ read 专属代ç �。
+
+### è¯è¨€æ��示推导
+
+`langFromPath`(在 `read-render.ts` ä¸ï¼‰é€šè¿‡ä¸€å¼ 固定å°�表(`LANG_BY_EXTENSION`,覆盖常è§�æº�ç �ã€�é…�ç½®ã€�æ ‡è®°æ‰©å±•å��)把文件扩展å��æ˜ å°„åˆ°è¯æ³•高亮è¯è¨€ id。它读å�–最å�Žä¸€ä¸ªè·¯å¾„段与最å�Žä¸€ä¸ªç‚¹ä¹‹å�Žçš„æ‰©å±•å��,大å°�写ä¸�æ•�感,并对以下情况返回 `undefined`:dotfile(`.gitignore`)ã€�æ— æ‰©å±•å��(`/etc/hosts`)ã€�结尾的点ã€�以å�Šä»»ä½•未知扩展å��â€”â€”æ¤æ—¶ card çœ�ç•¥ `lang`,UI 渲染纯文本。该表ä¸�是å�¯è°ƒé¡¹ï¼ˆtunable):它是 UI å�¯å¿½ç•¥çš„展示æ��示,而é�žéš�部署å�˜åŒ–的选择,未知扩展å��é™�级为纯文本而é�žå¤±è´¥ã€‚它有æ„�ä¿�æŒ�å°�规模而é�žç©·å°½çš„è¯è¨€æ³¨å†Œè¡¨ï¼›æ‰©å±•它是一行表项新增。
+
+## Alternatives considered
+
+**在 `presentResult` ä¸é‡�æ–°è§£æž� `N: text` é�¢å�‘模型文本。** å·²å�¦å†³ï¼šç»“构化行数组将ä¸�å¾—ä¸�通过按第一个 `: ` 切分æ¯�行æ�¥é‡�建,这既有æ§ä¹‰ï¼ˆæŸ�行文本自身å�« `: `),å�ˆä¸¢å¤±ç²¾ç¡®çš„ `totalLines`(脚注å�ªåœ¨éƒ¨åˆ†åˆ†æ”¯ä¸é™ˆè¿°å®ƒï¼‰ï¼Œå¹¶åœ¨æ¸²æŸ“æ ¼å¼�å�˜åŒ–æ—¶ç«‹å�³å¤±æ•ˆã€‚`presentationMeta` æ�ºå¸¦å·²ç»�结构化的数æ�®ï¼Œæ— 需é‡�æ–°è§£æž�。
+
+**è°ƒç”¨ä¾§ä¹Ÿæ‰“æ ‡ç¾ï¼ˆ`ReadCallView`),镜åƒ�终端 card 的两侧对称。** å·²å�¦å†³ï¼šread 调用在执行å‰�没有内容ã€�没有行数组ã€�没有总数——调用侧 read card 会是一个空å�˜ä½“,é‡�å¤� `GenericCallView`(`kind: 'read'`,跟éš�定ä½�)已ç»�表达的东西。终端 card ä¸¤ä¾§éƒ½æ‰“æ ‡ç¾æ˜¯å› 为终端调用确实æ�ºå¸¦è°ƒç”¨æ—¶æ•°æ�®ï¼ˆå‘½ä»¤ã€�cwd);read 调用没有。
+
+**把结构化窗å�£æ”¾è¿›æ–°æœ�务或æ—�路通é�“而é�ž `meta`。** å·²å�¦å†³ï¼š`meta` 是既有的æŒ�久化展示通é�“(write/edit 的应用 diff å°±æ�它),它éš�会è¯�日志å…�è´¹å›žæ”¾ï¼Œæ— éœ€æ–°æŽ¥çº¿ã€‚æœ�务会é‡�æ–°å�‘明事件日志已æ��供的æŒ�久化与回放。
+
+**用 merge-extensible union 而é�žå°�é—æ ‡ç¾ã€‚** 出于[渲染æ„�图 union](../architecture/2026-07-02-tool-render-intent-union.md) å°�é—的相å�Œç�†ç”±å�¦å†³ï¼šæ–° card 需è¦�消费代ç �æ�¥æ¸²æŸ“å®ƒï¼Œå› æ¤è¢«æ¶ˆè´¹è€…é�™é»˜ä¸¢å¼ƒçš„å�˜ä½“比编译错误更糟。把 `read` åŠ å…¥å°�é— union 是扩展它的许å�¯æ–¹å¼�——æ¯�个在 `card` 上 switch 的消费者都继ç»ç¼–è¯‘ï¼Œå› ä¸ºæ–°æˆ�员è�½å…¥å…¶ generic default,而想è¦�富视图的消费者新增自己的分支。
+
+## Consequences
+
+`ToolResultView` 多了第四个æˆ�员。æ¯�个在 `card` 上 switch 的消费者都继ç»ç¼–译:TUI 和当å‰� Web 客户端把未知 card 路由到其 generic 路径,而 read card æ�ºå¸¦ `content` 使该路径显示文件文本。从 `lines`/`lang`/`totalLines` 渲染带行å�·ã€�è¯æ³•高亮视图的 Web å‰�端是å�•独的å�Žç» PR;本 PR 是让数æ�®å�¯è§¦å�Šçš„å�Žç«¯ã€‚在它è�½åœ°å‰�,read 在å�„处的渲染与之å‰�完全一致(generic 文本 card)。
+
+read 工具现在为æ¯�次顶层 read 计算 `presentationMeta`,这是对已在手数æ�®çš„一次å°�投影(一次 `lines.map` 和一次 `langFromPath` 调用)。meta éš�会è¯�日志æŒ�ä¹…åŒ–ï¼Œå› æ¤ read 结果在ç£�盘上略大——它已渲染为文本的行数组,现在也以结构化形å¼�å˜åœ¨ã€‚
+
+## Testing
+
+`packages/fs/tool-fs/tests/read-render.spec.ts` å�•测 `langFromPath`(已知扩展å��的大å°�写ä¸�æ•�感ã€�扩展å��在最å�Žä¸€æ®µä¸Žæœ€å�Žä¸€ä¸ªç‚¹ä¹‹å�Žè¯»å�–ã€�以å�Š `undefined` å�„情况:dotfileã€�æ— æ‰©å±•å��ã€�结尾的点ã€�未知)与 `readMetaFromMeta`(å�«ä¸Žä¸�å�« `lang` 的良构收窄,以å�Šæ¯�ç§�æ‹’ç»�:é�žå¯¹è±¡ã€�数组ã€�缺失或类型错误的 `path`/`totalLines`/`lines`ã€�畸形行项ã€�é�žå—符串 `lang`,以å�Šâ€”â€”å› ä¸ºè¯¥å‡½æ•°æ”¶çª„æŒ�久化的 opaque `meta` 边界——良构类型的回放 JSON ä»�å�¯èƒ½æ�ºå¸¦çš„è¯ä¹‰æ— 效路径:ä¸�是 1-based æ•´æ•°çš„ `offset`ã€�å°�于 `offset` 的首行 `number`ã€�ä¸�是 1-based 整数的行 `number`(`0`ã€�`1.5`ã€�`NaN`ã€�`Infinity`)ã€�ä¸�是é�žè´Ÿæ•´æ•°çš„ `totalLines`(`-1`ã€�`1.5`ã€�`NaN`)ã€�以å�Šè¡Œå�·é‡�å¤�ã€�递å‡�或超过 `totalLines` çš„æƒ…å†µï¼›å¹¶ä¸”æ”¶çª„æ£ `offset` 处的空窗å�£ï¼ˆå—节上é™�低于首个选ä¸è¡Œï¼‰ï¼‰ã€‚`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗å�£ï¼ˆå�«ä¸Žä¸�å�« `lang` æ��示)作为 `meta` 附上ã€�`presentResult` 把它收窄为æ�ºå¸¦å‰¥ä¿¡å°� `content` çš„ `card: 'read'` 视图ã€�以å�Šå�„æ‹’ç»�路径(错误结果ã€�é�žå�•文本内容ã€�meta 有效但信å°�畸形ã€�ä¿¡å°�有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的æº�文件ä¿�æŒ�é€�文件 100% 覆盖率。本 PR æ�ºå¸¦çš„æ˜¯æŒ�久化 meta 与扩展å�Žè�”å�ˆç±»åž‹çš„å¿«ç…§è¯�æ�®ï¼Œè€Œé�žæ–°æ¸²æŸ“视图的è¯�æ�®ï¼šé‡�录的 ACP session fixtures(`fs-read`ã€�`fs-read-window`ã€�`fs-edit`ã€�`fs-policy-reject`ã€�`fs-write-overwrite`ã€�`parallel-tool-calls`ã€�`workspace-context`ã€�`workspace-edit`)钉ä½�æŒ�久化的读å�– `meta`(å�« `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` é’‰ä½�å››æˆ�员的 `ToolResultView` è�”å�ˆç±»åž‹ã€‚已渲染读å�– card çš„ keyless 快照与组装应用 transcript 属于消费该视图的å�Žç» Web PRï¼Œå› ä¸ºæœ¬ PR ä¸�新增任何é�¢å�‘产å“�用户å�¯è§�的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读å�– card(`transcript.ts` 把 `card: 'read'` 当作 `card: 'generic'` 处ç�†ï¼‰ï¼Œå› æ¤å…¶è¾“出ä¿�æŒ�ä¸�å�˜ã€‚`apps/cli` çš„ `parallel-file-reads` 终端 golden(`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`)æ£é’‰ä½�这一点:一次真实回放执行 read 工具ã€�ç»�æ–°çš„ `card: 'read'` 门渲染,golden çš„ dim-Markdown 行与本 card 出现å‰� generic read 所产出的é€�å—节一致。
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 Note 以 `read` 结果分支扩展的 `card` æ ‡ç¾è¯�汇。
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 拥有本 Note 用�投影 read 窗�的 `presentationMeta` �久化通�。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 客户端消费结构化 card 的先例;read card �循相�的生产者模�,仅结果侧。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
new file mode 100644
index 0000000000..dae8f18b4d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-07-30-web-result-card-frontend.md
+2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd
+2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
new file mode 100644
index 0000000000..d6f4785e83
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
@@ -0,0 +1,49 @@
+# Agent Note: Web result card frontend — rendering the web render intent in the browser
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-web-result-card-frontend.zh.md)
+
+## Problem
+
+The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary.
+
+## Decision
+
+`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
+
+One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `æ�¥æº�列表已截æ–` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截æ–` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
+
+**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
+
+**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
+
+The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
+
+## Consequences
+
+`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free.
+
+A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change.
+
+## Alternatives considered
+
+**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic.
+
+**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it.
+
+**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render.
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server.
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
new file mode 100644
index 0000000000..ed95cbe39f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Web result �片�端 —— 在�览器渲染 web 渲染�图
+
+Status: implemented
+
+[English](2026-07-30-web-result-card-frontend.md) | 䏿–‡
+
+## Problem
+
+`web_search` å’Œ `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` æ ‡ç¾è�”å�ˆ,æ�ºå¸¦ç»“构化的被引用 sources åŠ å�¯é€‰çš„ provider answer(`kind: 'search'`),或抓å�–çš„ URL å�Šå…¶ HTTP 状æ€�(`kind: 'fetch'`)。该视图早已抵达æµ�览器 —— hostã€�connectionã€�runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完æˆ�çš„ web 调用å�ªæ¸²æŸ“为摊平的模型å�¯è§�文本,æ£æ˜¯å¥‘约笔记所解释的ã€�结构化视图è¦�替代的那ç§�有æ�Ÿæ¸²æŸ“。`web_search` 到达读者时是æ¯�个 source 一行自由文本 markdown,而é�žå�¯ç‚¹å‡» source 的引用列表;`web_fetch` 是它的 markdown æ£æ–‡,没有检索摘è¦�。
+
+## Decision
+
+`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完æˆ�çš„ web 检索,web 调用的æ¯�个 Web 渲染点都通过它消费 `web` 渲染æ„�图:键控的 chat 工具行(`web_search`/`web_fetch`)ã€�`GenericToolCard` 渲染点兜底,以å�Šè¯¦æƒ…é�¢æ�¿çš„ Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转æˆ�组件 props 的地方,镜åƒ� `terminal-card-model.ts`,å› æ¤æ²¡æœ‰ä¸¤ä¸ªæ¸²æŸ“点会对一次 web 调用的显示产生分æ§ã€‚它返回 null —— 走通用路径 —— 对è¿�行ä¸çš„调用(web å�¡ç‰‡æ˜¯ result-only çš„,å› ä¸ºå·¥å…·ä¿�ç•™ generic pending 视图)ã€�对 result view ä¸�是 web å�¡ç‰‡çš„已结算调用(包括本客户端版本ä¸�认识的 `card` 值,它ç»� wire æŠµè¾¾å› è€Œä¸�能被信任为已编译的å�˜ä½“)ã€�对 generic result view(web 工具的错误路径返回 generic å�¡ç‰‡,其文本由通用路径ä¿�留)ã€�以å�Šå¯¹æœ¬å®¢æˆ·ç«¯ç‰ˆæœ¬ä¸�认识 `kind` çš„ web å�¡ç‰‡ï¼ˆæ›´æ–°çš„ host ç»� wire å�‘æ�¥çš„值,读作 fetch 会画出空 URL å’Œ `HTTP undefined`)。
+
+一个组件绘制两ç§� kind,ç”± `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;æ¯�个 source 是一个安全外链,ä»¥å…¶æ ‡é¢˜ä¸ºæ ‡ç¾,provider æœªç»™æ ‡é¢˜æ—¶ä»¥å…¶ä¸»æœºå��ä¸ºæ ‡ç¾,下方是 snippet 与å�‘布日期,工具截æ–列表时显示 `æ�¥æº�列表已截æ–` æ��示。`fetch` 显示一个紧凑摘è¦�:带链接的最终 URLã€�å…¶ HTTP 状æ€�ã€�以å�Š `内容已截æ–` æ��示。用一个组件而é�žä¸¤ä¸ª,å› ä¸ºä¸¤è€…éƒ½æ˜¯æ¸²æŸ“ä¸ºå�Œä¸€å�¡ç‰‡æ—�çš„ web 检索 —— è¿™æ£æ˜¯å¥‘约把它们放在一个 `card` æ ‡ç¾ä¸‹ã€�用 `kind` åˆ¤åˆ«çš„åŽŸå› ã€‚
+
+**链接的安全性沿用 MarkdownText 对ä¸�å�—信任的 assistant 链接所用 allowlist çš„ http(s) å�集。** MarkdownText 还å…�许 `mailto:`,æ¤å¤„刻æ„�æŽ’é™¤ï¼Œå› ä¸ºæ£€ç´¢ URL ç»�ä¸�会是邮件地å�€ã€‚一个 source 或 fetch URL 仅当其å��议为 `http:` 或 `https:` æ—¶æ‰�æˆ�为å�¯å¯¼èˆªé”šç‚¹ï¼Œå¸¦ `target="_blank"` å’Œ `rel="noopener noreferrer"`ï¼›`javascript:`/`data:`/`file:`/`mailto:` URL æˆ–æ— æ³•è§£æž�çš„å—符串渲染为纯文本ã€�æ— href。web 工具返回的 result content 是模型创作的,未ç»�验è¯�抵达本组件,å› æ¤åƒ� assistant markdown ä¸€æ ·è¢«å½“ä½œä¸�å�—信任处ç�†ã€‚æ ‡ç¾ä»Žæ ‡é¢˜å›žé€€åˆ°ä¸»æœºå��å†�回退到原始 URL,å› æ¤å�³ä¾¿æ ‡é¢˜ç¼ºå¤±ä¸” URL æ— æ³•è§£æž�,source 也总能读作æŸ�个东西。
+
+**å‡ ä½•é•œåƒ� CodeBlock/TerminalBlock**(12px 圆角ã€�code-block 表é�¢ã€�16px 垂直外边è·�),使 web å�¡ç‰‡ä¸Žå®ƒä»¬è¯»ä½œä¸€å®¶ã€‚é•¿ source 列表在 `maxSources` 处折å� ,用 TerminalBlock 完全相å�Œçš„分割算术å�šå¤´/尾折å� (`ceil(max/2)` å¤´éƒ¨è¡ŒåŠ å‰©ä½™å°¾éƒ¨ï¼‰,ä½¿é•¿æ£æ–‡çš„åˆ‡ç‰‡åœ¨ä¸¤å¼ å�¡ä¹‹é—´ä¸€è‡´ã€‚source 列表是散文而é�žæŒ‰åˆ—对é½�的输出,所以它æ£å¸¸æ�¢è¡Œ,而ä¸�åƒ�终端å�¡ç‰‡çš„è¾“å‡ºé‚£æ ·æ¨ªå�‘滚动 —— 这是与 TerminalBlock 唯一刻æ„�的分æ§ã€‚
+
+å�¡ç‰‡åœ¨ chat 行ä¸**常驻**于摘è¦�行之下,上é™� `CHAT_WEB_MAX_SOURCES`(8)—— 原è¯è‡ªèº«é»˜è®¤ 16 的一å�Š,é�¢æ�¿ä¿�ç•™ 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端å�¡ç‰‡æ‰€ç”»çš„æ‘˜è¦�é�¢å¯¹é˜…读é�¢çš„å�Œä¸€åˆ’分,以å�Š `BashRow` 所用的å�Œä¸€å¸¸é©»å§¿æ€�。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;è¡Œä»…æ ¹æ�®å·¥å…·å��判别以选å�–å…¶å›¾æ ‡ï¼ˆsearch 对 browseï¼‰ä¸Žæ ‡é¢˜ï¼ˆ`Search`/`Fetch`)。没有自己键控行的 web 声明工具è�½åˆ° `GenericToolCard`,它长出å�Œä¸€å¼ 常驻å�¡ç‰‡ã€‚详情é�¢æ�¿ä»¥åŽŸè¯çš„完整 source é¢�度渲染å�¡ç‰‡,并在其下方渲染摊平的模型å�¯è§�结果内容:`web_fetch` å�¡ç‰‡å�ªæ�ºå¸¦ URL 与状æ€�,å› æ¤å…¶æŠ“å�–æ£æ–‡å�ªåœ¨æ¤å¤„å�¯è¯»ã€‚
+
+## Consequences
+
+`WebBlock` å�ªè¯» web view çš„å—æ®µ,å› æ¤å®ƒæ˜¯æ¸²æŸ“æ„�图所æ�ºå¸¦å†…容的纯函数 —— æ— ä¼šè¯�查找,与产出该视图的 presenter ä¸€æ ·å›žæ”¾å®‰å…¨,且ä¸�å�ŒäºŽç»ˆç«¯å�¡ç‰‡å®ƒä¸�需è¦� cwd è§£æž�,å› ä¸º web view ä¸�æ�ºå¸¦è·¯å¾„。没有 `web` 能力的 UI(TUI)ä»�得到契约的回退 `content`;工具的 result 形状没有任何改å�˜ã€‚answer å¤�用 `MarkdownText`,å› æ¤ answer 自身的ä¸�å�—信任链接处ç�†ä¸Ž GFM 渲染å…�费获得。
+
+一æ�¡ç‹¬ç«‹çš„å�Žç» PR 会统一整行折å� /展开交互,并把æ¯�å¼ å¸¸é©»å�¡ç‰‡ï¼ˆterminalã€�diffã€�web)一次性翻æˆ� expand-gated;本å�¡ç‰‡é�µå¾ªå½“å‰�的常驻约定,而é�žæŠ¢å…ˆå�šé‚£æ¬¡æ”¹åŠ¨ã€‚
+
+## Alternatives considered
+
+**两个组件,æ¯�ç§� kind 一个。** æ‹’ç»�:两ç§�形状共享å�¡ç‰‡å¤–框ã€�安全链接处ç�†ã€�æˆªæ–æ��示,而契约已ç»�把它们的差异表达为一个 `card` æ ‡ç¾ä¸‹çš„ `kind` 判别;两个组件会é‡�å¤�共享表é�¢å¹¶æ‹†åˆ†å®‰å…¨é“¾æŽ¥é€»è¾‘。
+
+**é‡�è§£æž�模型å�¯è§�的渲染文本,而é�žæ¶ˆè´¹ç»“构化视图。** å› å¥‘çº¦ç¬”è®°ç»™å‡ºçš„å�Œä¸€ç�†ç”±æ‹’ç»�:`web_search` 的渲染把æ¯�个 source çš„å—æ®µåŽ‹ç¼©æˆ�一行自由文本ã€�ä»¥æ ‡é¢˜æˆ–ä¸»æœºå��ä¸ºæ ‡ç¾,所以é‡�è§£æž�æ— æ³•æ�¢å¤� `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` æ˜¯å”¯ä¸€å¿ å®žæ�¥æº�,è¿™æ£æ˜¯å�Žç«¯ PR æ·»åŠ å®ƒçš„åŽŸå› ã€‚
+
+**ä¸�åŠ å��è®® allowlist 直接渲染裸锚点。** æ‹’ç»�:URL åœ¨æ¤æŽ¥ç¼�处是模型创作ã€�未ç»�验è¯�çš„,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还å…�许 `mailto:`)的 http(s) å�集,å› æ¤ä¸�å�—ä¿¡ä»»çš„æ£€ç´¢é“¾æŽ¥æ— è®ºåœ¨ä½•å¤„æ¸²æŸ“éƒ½è¡Œä¸ºç›¸å�Œã€‚
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两ç§� kind;æ ‡é¢˜-或-主机å��-或-原始 URL çš„æ ‡ç¾å›žé€€;两ç§� kind 上的安全链接属性(http(s) URL æˆ�为带 `target`/`rel` 的外链,`javascript:`/`file:`/æ— æ³•è§£æž�çš„ URL æ¸²æŸ“ä¸ºæ— href 的纯 span);snippet 与日期在å˜åœ¨/为空/缺失时的显示或çœ�ç•¥;ç”±æ ‡å¿—ä½�æŽ§åˆ¶çš„æˆªæ–æ��示;以å�Š source 列表高度上é™�å�Šå…¶å¤´/尾切片与展开/收起控件,å�«é»˜è®¤ä¸Šé™�。
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` 在æ¯�个接线接ç¼�镜åƒ� `terminal-card.spec.tsx`:`webCardModel` 的派生投影æ¯�个 source å—æ®µã€�其截æ–与缺失 answer 的支路ã€�fetch 派生ã€�以å�Šæ¯�个 null 支路(è¿�行ä¸ã€�null result viewã€�generic result viewã€�未知 card æ ‡ç¾ã€�未知 web `kind`);键控 `WebRow` 对两ç§� kind 的常驻å�¡ç‰‡ã€�比é�¢æ�¿æ”¶å¾—æ›´ç´§ã€�其仅摘è¦�行的è¿�行ä¸ä¸Žå¤±è´¥æ”¯è·¯;`GenericToolCard` 兜底为 web 声明工具长出常驻å�¡ç‰‡ã€�并为é�ž web 调用ä¿�æŒ�纯行;详情é�¢æ�¿ Output 区对两ç§� kind —— å�« `web_fetch` æ£æ–‡æ‘Šå¹³åœ¨å…¶ URL/状æ€�å�¡ç‰‡ä¸‹æ–¹ —— å�Šå…¶å¯¹é�ž web 结果的摊平回退;以å�Šåœ¨ `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件ä½�于覆盖率 `exclude` 列表(`ui-conversation/src/*`),å› æ¤è¦†ç›–率è¿�行ä¸�度é‡�它。
+
+fixture(`packages/client/connection/src/client/fixture.ts`ï¼‰æ·»åŠ turn 66(`web_search`)与 67(`web_fetch`),内è�”撰写,å› ä¸ºå®¢æˆ·ç«¯ fixture æ— æ³• import web 工具:turn 66 çš„ result view æ�ºå¸¦ä¸€ä¸ª answer 与三个 source,演练引用列表(一个带 snippet ä¸Žæ—¥æœŸçš„æœ‰æ ‡é¢˜ sourceã€�ä¸€ä¸ªæ— æ ‡é¢˜å› è€Œä»¥ä¸»æœºå��æ ‡æ³¨é“¾æŽ¥çš„ sourceã€�ä¸€ä¸ªæœ‰æ—¥æœŸæ— snippet çš„ source)并开å�¯æˆªæ–æ��示;turn 67 æ�ºå¸¦æŠ“å�–çš„ URL 与一个 200 状æ€�。两者都ä¿�ç•™ generic pending call view,仅在 result æ—¶æ·»åŠ `web` å�¡ç‰‡,匹é…�契约的 result-only web 形状,且以真实工具命å��,使其命ä¸é”®æŽ§ `WebRow`。它们被排在 todo turn(é‡�ç¼–å�·ä¸º 68)之å‰�,ç�†ç”±ä¸Žç»ˆç«¯ turn 相å�Œ:待定计划在下一个 `turn/start` 退休,所以排在其å�Žçš„ turn 会清空 dock çš„ plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` æœ�务。
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) —— æ·»åŠ `card: 'web'` result 支路并让两个工具å�‘出它的å�Žç«¯ PR;本æ�¡æ˜¯å®ƒæŽ¨è¿Ÿçš„å‰�端消费者。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本�所镜�的先例:一个 `ui-primitives` block�一处 card-model 派生�键控与兜底 chat 行�以�一个详情��支路,用于 `terminal` 渲染�图。
+- [å·¥å…·è°ƒç”¨å‘ˆçŽ°çš„æ ‡ç¾åŒ– render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` æ ‡ç¾è¯�汇;Web 客户端现在是 `web` 支路的完整消费者。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml
new file mode 100644
index 0000000000..a6658971b1
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md
+2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1
+2026-07-30-web-search-card.zh.md: 714a2979730dc2c83f6cfc1cf6d21978755a2d95
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md
new file mode 100644
index 0000000000..4c7ae6c8c6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md
@@ -0,0 +1,65 @@
+# Agent Note: Web search card — the grep and glob render intent reaches the browser
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-web-search-card.zh.md)
+
+## Problem
+
+The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`shape: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`shape: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text.
+
+This is the follow-up the search render card note names: that PR was the backend contract and its two producers; this PR is the web consumer.
+
+## Decision
+
+`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation.
+
+The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card.
+
+One component draws both shapes, discriminated by `kind`, because `grep` and `glob` are the same visual object — a search result. `SearchMatchesBlockProps` (`kind: 'matches'`) and `SearchPathsBlockProps` (`kind: 'paths'`) keep each shape's fields required rather than a single interface with everything optional. The component flattens whichever shape it holds into one list of render rows — a file header row plus its match rows for the matches shape, one path row per path for the paths shape — so the height cap counts a file header as one row exactly as a match line or a path, and the head/tail slice arithmetic is `TerminalBlock`'s (`ceil(max/2)` head, the remainder tail), so a long search result and a long command output cut at the same place across the two cards.
+
+The component's contract:
+
+- **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text.
+- **Flat path list.** The paths shape renders one path per row, no headers.
+- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹� · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹� · {m} 个文件`, or `{n} 个路径`).
+- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the raw `tool/result` content (the search view carries no result text; a UI without a card falls back to that raw content), not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces the block's own flattened result text as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its raw text adds nothing and is dropped.
+- **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding.
+- **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`.
+- **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing.
+
+Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search card reads as one family with them; `white-space: pre` plus horizontal scroll is the shared deliberate divergence.
+
+### Render sites
+
+Three sites consume the derivation, mirroring the terminal card's placement exactly:
+
+- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.)
+- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle.
+- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section.
+
+`CHAT_SEARCH_MAX_LINES` (8) is the row cap, half the primitive's default the panel keeps, for the same reason as `CHAT_TERMINAL_MAX_LINES`: the chat flow is a summary surface read across many calls, the panel is the single-call reading surface.
+
+## Alternatives considered
+
+**Two card components, one per tool.** Rejected: `grep` and `glob` are the same visual object discriminated only by `kind`, so two components would duplicate the banner, the height cap, the copy control, and the no-wrap geometry. One component switching on `kind` is what the backend's single `card: 'search'` view is for.
+
+**A `SearchCallView` so the row renders a card while the search runs.** Rejected: the backend contract deliberately has no call-time search view — a search has no matches or paths before `execute`. The running row shows its summary alone, and `searchCardModel` returns null for a running block, which is faithful to what exists.
+
+**Reuse `TerminalBlock` or `CodeBlock`.** Rejected: neither models per-file collapsible groups or a folded capped-result summary, and both would need the grouped-matches shape bolted on. The three blocks share their geometry and font tokens instead, which is the only part where one implementation is correct for all.
+
+## Consequences
+
+`SearchBlock` reads only the search view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the search capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. Extending `ToolRow` with a `search` body prop adds one arm beside `terminal`; a call carries at most one card kind, so the two are never both present on a row.
+
+## Testing
+
+`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths.
+
+`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot.
+
+## Related
+
+- [Search render intent — grep and glob emit a structured search card](2026-07-30-search-render-card.md) — the backend contract and its two producers; this is its named web-consumer follow-up.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a tool's render intent reaches the browser through a `ui-primitives` block, a single `contract/*-card-model.ts` derivation, and the same three render sites.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary both cards consume.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md
new file mode 100644
index 0000000000..714a297973
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md
@@ -0,0 +1,65 @@
+# Agent Note:Web �索�片 —— grep 与 glob 的 render intent 到达�览器
+
+Status: implemented
+
+[English](2026-07-30-web-search-card.md) | 䏿–‡
+
+## Problem
+
+`grep` 与 `glob` 工具声明了一个仅在结果阶段å˜åœ¨çš„ `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`shape: 'matches'`)æ�ºå¸¦ grep 按文件分组的匹é…�,或 `SearchPathsResultView`(`shape: 'paths'`)æ�ºå¸¦ glob çš„æ‰�平路径列表,两者都带 `truncated`/`total` 截æ–ä¿¡å�·ã€‚该视图已ç»�到达æµ�览器 —— hostã€�connectionã€�runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:æ¯�个é�žç»ˆç«¯ã€�é�ž diff 的工具结果都è�½åˆ° generic å�¡ç‰‡ï¼Œæ¸²æŸ“é�¢å�‘模型的文本。想把æ�œç´¢ç»“果渲染æˆ�å�¯å±•开的按文件匹é…�分组ã€�或å�¯æ‰«è¯»çš„路径列表的 web å‰�端,å�ªæœ‰é‚£æ®µé¢„æ ¼å¼�化文本。
+
+è¿™æ£æ˜¯ search render card note 指å��çš„å�Žç»ï¼šé‚£ä¸ª PR 是å�Žç«¯å¥‘约和它的两个生产者,本 PR 是 web 消费者。
+
+## Decision
+
+`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完æˆ�çš„æ�œç´¢æ¸²æŸ“æˆ�两ç§�å½¢æ€�之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费æ�œç´¢ render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot çš„ `resultView` 转æˆ�组件 props 的唯一ä½�ç½®ï¼Œå› æ¤æ²¡æœ‰æ¸²æŸ“点é‡�新推导形æ€�。当结果视图ä¸�是æ�œç´¢å�¡ç‰‡æ—¶å®ƒè¿”回 null(走 generic 路径),包括ä»�在è¿�行的调用(æ�œç´¢å�¡ç‰‡ä»…在结果阶段å˜åœ¨ï¼Œ`execute` å‰�æ— å†…å®¹ï¼‰ã€�`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果ã€�terminal 结果视图ã€�本客户端版本ä¸�认识的 `card` 值ã€�`shape` æ˜¯æœ¬ç‰ˆæœ¬æ— æ³•ç¼–è¯‘çš„ `card: 'search'` 视图,以å�Š â€”â€” å› ä¸º `shape` 和分组/æ‰�平内容与 host schema å�ªå�šå—ç¬¦ä¸²æ ¡éªŒçš„é‚£å�Œä¸€ä¸ªä¸�å�¯ä¿¡ wire 帧å�Œè¡Œ —— 一个 `shape` 已知但 `files`/`paths` ç¼ºå¤±æˆ–æ ¼å¼�错误的视图(å�¦åˆ™ä¼šè®© `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(ä¸�是 `kind` —— å�Žç«¯æŠŠ `kind` 留给 call view çš„é€‰å›¾æ ‡ç¾ï¼‰ï¼›`SearchBlock` 自身的 prop ä»�是 `kind`,由本推导从 `shape` æ˜ å°„å¾—åˆ°ã€‚
+
+与终端å�¡ç‰‡çš„ä¸�对称是刻æ„�的,继承自å�Žç«¯å¥‘约:`terminalCardModel` å�Œæ—¶è¯» `callView` å’Œ `resultView`ï¼Œå› ä¸ºå‘½ä»¤ã€�cwdã€�description 在调用时就å˜åœ¨ï¼›`searchCardModel` å�ªè¯» `resultView`ï¼Œå› ä¸ºæ�œç´¢çš„匹é…�或路径å�ªåœ¨æ‰§è¡Œå�Žå˜åœ¨ã€‚å› æ¤è¿�行ä¸çš„æ�œç´¢è¡Œå�ªæ˜¾ç¤ºæ‘˜è¦�,没有å�¡ç‰‡ã€‚
+
+一个组件绘制两ç§�å½¢æ€�,用 `kind` åŒºåˆ†ï¼Œå› ä¸º `grep` å’Œ `glob` 是å�Œä¸€ä¸ªè§†è§‰å¯¹è±¡ —— 一个æ�œç´¢ç»“果。`SearchMatchesBlockProps`(`kind: 'matches'`)和 `SearchPathsBlockProps`(`kind: 'paths'`)让æ¯�ç§�å½¢æ€�çš„å—æ®µä¿�æŒ�必填,而ä¸�æ˜¯æ‰€æœ‰å—æ®µéƒ½å�¯é€‰çš„å�•一接å�£ã€‚组件把它æŒ�有的形æ€�压平æˆ�一个渲染行列表 —— matches å½¢æ€�æ˜¯ä¸€ä¸ªæ–‡ä»¶å¤´è¡ŒåŠ å®ƒçš„åŒ¹é…�行,paths å½¢æ€�是æ¯�个路径一行 —— 于是高度上é™�把一个文件头当作一行æ�¥è®¡ï¼Œä¸Žä¸€æ�¡åŒ¹é…�行或一个路径相å�Œï¼Œå¤´/尾切片算术就是 `TerminalBlock` 的(`ceil(max/2)` å¤´ï¼Œå…¶ä½™ä¸ºå°¾ï¼‰ï¼Œå› æ¤ä¸€ä¸ªé•¿æ�œç´¢ç»“æžœå’Œä¸€æ®µé•¿å‘½ä»¤è¾“å‡ºåœ¨ä¸¤å¼ å�¡ç‰‡é—´åœ¨å�Œä¸€å¤„截æ–。
+
+组件契约:
+
+- **按文件分组的匹é…�,é€�文件å�¯æŠ˜å� 。** æ¯�ä¸ªæ–‡ä»¶æ˜¯ä¸€ä¸ªå¤´è¡Œï¼ˆåŠ ç²—è·¯å¾„åŠ å®ƒçš„åŒ¹é…�计数,整行å�³æŠ˜å� 控件),å�Žé�¢è·Ÿå®ƒçš„ `lineNumber: line` 行。折å� 一个组会把它的匹é…�行从压平列表和高度上é™�的算术里去掉,但ç»�ä¸�从å¤�制文本里去掉。
+- **æ‰�平路径列表。** paths å½¢æ€�æ¯�è¡Œä¸€ä¸ªè·¯å¾„ï¼Œæ— å¤´è¡Œã€‚
+- **æˆªæ–æŒ‡ç¤ºã€‚** `truncated` 时,横幅摘è¦�把截æ–å‰�总数折入 —— grep 为 `显示 X / å…± N 处匹é…� · K 个文件`,glob 为 `显示 X / å…± N 个路径` —— å› æ¤å�¡ç‰‡ç»�ä¸�把一个被截æ–的页é�¢å‘ˆçŽ°ä¸ºå®Œæ•´ç»“æžœã€‚æœª `truncated` 时摘è¦�æ˜¯ä¸€ä¸ªæœ´ç´ çš„ç»“æž„è®¡æ•°ï¼ˆ`{n} 处匹é…� · {m} 个文件`,或 `{n} 个路径`)。
+- **被截æ–结果的æ�¢å¤�脚注。** å�¡ç‰‡å�ªæŒ�有ä¿�留的那一页,但通往其余部分的定ä½�符 —— grep/glob çš„ `Full … stored at: ` 脚注 —— å�ªå˜åœ¨äºŽåŽŸå§‹ `tool/result` 内容里(æ�œç´¢è§†å›¾ä¸�æ�ºå¸¦ç»“果文本;没有å�¡ç‰‡çš„ UI 回退到那段原始内容),而é�žç»“构化的 matches/paths ä¸ã€‚由于æ¯�个渲染点都用å�¡ç‰‡æ›¿æ�¢äº†åŽŸå§‹ç»“æžœï¼Œ`searchCardModel` åœ¨ï¼ˆä¸”ä»…åœ¨ï¼‰ç»“æžœè¢«æˆªæ–æ—¶æŠŠ block 自身压平å�Žçš„结果文本作为 `SearchCardModel.recovery` 暴露出æ�¥ï¼Œæ¯�个渲染点把它画在å�¡ç‰‡ä¸‹æ–¹ã€‚没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截æ–的结果æ�ºå¸¦äº†æ¯�一行,其原始文本ä¸�å¢žåŠ ä»»ä½•ä¿¡æ�¯ï¼Œå› æ¤è¢«ä¸¢å¼ƒã€‚
+- **ä¸�软æ�¢è¡Œã€‚** 结果行在一个横å�‘滚动的盒å�里 `white-space: pre`ï¼Œå› æ¤ä¸€æ�¡é•¿åŒ¹é…�行或一个深路径横å�‘滚动而ä¸�折å� 。
+- **带展开控件的高度上é™�。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,ä¸é—´ä¸€ä¸ªæŒ‰é’®æŠ¥å‘Šè¢«éš�è—�的行数,形状和算术与 `TerminalBlock` 相å�Œã€‚
+- **å¤�制。** å¤�制控件写入整个结构化结果 —— æ¯�个文件与匹é…�,或æ¯�个路径 —— æ— å…³é«˜åº¦ä¸Šé™�或哪些组被折å� ï¼Œå› æ¤å‰ªè´´æ�¿æ�ºå¸¦çš„æ˜¯ç»“果本身,而ä¸�是å�¡ç‰‡æ¤åˆ»æ�°å¥½æ˜¾ç¤ºçš„内容。
+
+å‡ ä½•ã€�圆角ã€�å—体镜åƒ� `CodeBlock` 与 `TerminalBlock`ï¼Œå› æ¤æ�œç´¢å�¡ç‰‡ä¸Žå®ƒä»¬è¯»ä½œå�Œä¸€æ—�ï¼›`white-space: pre` åŠ æ¨ªå�‘滚动是它们共享的刻æ„�分æ§ã€‚
+
+### 渲染点
+
+三个渲染点消费该推导,与终端�片的��完全一致:
+
+- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件å�Œæ—¶æ³¨å†Œåˆ° `conversation.chat.toolview` keyed hole çš„ `grep` 与 `glob` 键下,并把å�¡ç‰‡ä½œä¸ºå¸¸é©»ï¼ˆresident)渲染在摘è¦�行下方,上é™�为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端å�¡ç‰‡é‡‡å�–的姿æ€�相å�Œã€‚两个工具å��共用å�Œä¸€è¡Œï¼Œå› 为推导出的 `kind` 决定形æ€�,第二个组件å�ªä¼šé‡�å¤�它。被截æ–结果的æ�¢å¤�脚注画在å�¡ç‰‡ä¸‹æ–¹ã€‚å› ä¸º keyed 行å� æ�®äº†è¿™ä¸ªæ¸²æŸ“槽,一个没有æ�œç´¢å�¡ç‰‡çš„已结算调用 —— 出错的æ�œç´¢ï¼ˆgrep/glob 出错时ä¸�产出结果视图)ã€�æˆ�功的嵌套 `run_code` å�æ´¾å�‘(å�Žç«¯ä¸�为其计算 `presentationMeta`,故 `resultView` 为 null)ã€�或旧日志的 generic 结果 —— å�¦åˆ™å�ªä¼šæ˜¾ç¤ºæ‘˜è¦�而丢失内容;该行把这段é�¢å�‘模型的文本作为 fallback body 暴露出æ�¥ï¼Œåˆ¤æ�®æ˜¯ `search === null && 已结算`,而é�žä»…å‡é”™è¯¯çжæ€�。(该常驻姿æ€�与当å‰�çš„ terminal/diff å�¡ç‰‡ä¸€è‡´ï¼›ä¸€ä¸ªå�•独的å�Žç» PR 会统一整行折å� /展开交互并一次性翻转所有常驻å�¡ç‰‡ —— ä¸�在本 PR 范围内。)
+- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body ä¼ å…¥ï¼Œä¸Ž `terminal` 用的是å�Œä¸€åˆ†æ”¯ï¼šæ²¡æœ‰ keyed 行的 `grep`/`glob` 结果(å�‘å¸ƒåº”ç”¨é‡Œæ²¡æœ‰ï¼Œå› ä¸ºä¸¤è€…éƒ½æ³¨å†Œäº†ï¼‰ä»�在行的展开开关å�Žæ¸²æŸ“å…¶å�¡ç‰‡ï¼Œå¹¶å¸¦æ�¢å¤�脚注。
+- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染�片,��脚注画在其下方,�留 JSON Input 段。
+
+`CHAT_SEARCH_MAX_LINES`(8)是行内上�,为 primitive 默认值的一�(panel �留默认值),�由与 `CHAT_TERMINAL_MAX_LINES` 相�:chat �是跨多次调用扫读的摘�表�,panel 是�次调用的阅读表�。
+
+## Alternatives considered
+
+**两个å�¡ç‰‡ç»„件,æ¯�个工具一个。** å�¦å†³ï¼š`grep` 与 `glob` 是仅由 `kind` 区分的å�Œä¸€è§†è§‰å¯¹è±¡ï¼Œä¸¤ä¸ªç»„件会é‡�å¤�横幅ã€�高度上é™�ã€�å¤�制控件与ä¸�æ�¢è¡Œå‡ 何。一个按 `kind` åˆ†æ”¯çš„ç»„ä»¶æ£æ˜¯å�Žç«¯é‚£ä¸ªå�•一 `card: 'search'` 视图的用途。
+
+**åŠ ä¸€ä¸ª `SearchCallView`,让行在æ�œç´¢è¿�行时就渲染å�¡ç‰‡ã€‚** å�¦å†³ï¼šå�Žç«¯å¥‘约刻æ„�没有调用阶段的æ�œç´¢è§†å›¾ —— æ�œç´¢åœ¨ `execute` å‰�没有匹é…�或路径。è¿�行ä¸çš„行å�ªæ˜¾ç¤ºæ‘˜è¦�,`searchCardModel` 对è¿�行å�—返回 nullï¼Œå¿ å®žäºŽå®žé™…å˜åœ¨çš„东西。
+
+**å¤�用 `TerminalBlock` 或 `CodeBlock`。** å�¦å†³ï¼šä¸¤è€…都ä¸�建模é€�文件å�¯æŠ˜å� 的组或折å� å¼�æˆªæ–æ‘˜è¦�,都需è¦�把按文件分组的形æ€�硬塞进去。三个å�—è½¬è€Œå…±äº«å‡ ä½•ä¸Žå—体 token,那是唯一一处一个实现对三者都æ£ç¡®çš„部分。
+
+## Consequences
+
+`SearchBlock` å�ªè¯»æ�œç´¢è§†å›¾çš„å—æ®µï¼Œå› æ¤ä¿�æŒ�为 render intent 所æ�ºå†…容的纯函数 —— æ— ä¼šè¯�查询,与产生该视图的 presenter ä¸€æ ·å�¯é‡�放。没有æ�œç´¢èƒ½åŠ›çš„ UI ä»�得到 bridge 的围æ �回退;工具的结果形æ€�没有任何改å�˜ã€‚ç»™ `ToolRow` 扩一个 `search` body prop å�ªåœ¨ `terminal` æ—�åŠ ä¸€ä¸ªåˆ†æ”¯ï¼›ä¸€æ¬¡è°ƒç”¨è‡³å¤šæ�ºå¸¦ä¸€ç§�å�¡ç‰‡ï¼Œå› æ¤ä¸¤è€…ç»�ä¸�å�Œæ—¶å‡ºçŽ°åœ¨ä¸€è¡Œã€‚
+
+## Testing
+
+`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两ç§� kindã€�折入摘è¦�的截æ–å‰�总数ã€�空结果分支ã€�é€�文件折å� /å†�展开且ä¸�å½±å“�邻居ã€�一个文件头与其匹é…�一起计为一个被截æ–行ã€�切å�£è�½åœ¨æ–‡ä»¶ä¸é—´æ—¶å°¾éƒ¨åˆ‡ç‰‡æ�¢å¤�其所属文件头ã€�跨两ç§�å½¢æ€�的头/尾上é™�å�Šå…¶å±•开控件(å�«æ— 尾与默认上é™�的边界),以å�Šå¤�制控件在接å�—与拒ç»�的剪贴æ�¿è·¯å¾„上写入整个结构化结果。
+
+`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定æ¯�个渲染点的接线:`searchCardModel` 对两ç§� kind 的推导ã€�截æ–ä¿¡å�·ã€�替æ�¢æ ‡é¢˜ã€�ä»…åœ¨æˆªæ–æ—¶æš´éœ²çš„æ�¢å¤�文本,以å�Šæ¯�个 null 分支(è¿�行ä¸ã€�æ— è§†å›¾ã€�genericã€�terminalã€�未知å�¡ç‰‡ã€�æœ¬ç‰ˆæœ¬æ— æ³•ç¼–è¯‘çš„ `kind`ã€�以å�Šä¸€ä¸ªå½¢æ€�缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(å�«æ�¢å¤�脚注),对照é�žæ�œç´¢çš„ args-JSON bodyï¼›`SearchRow` 对两ç§� kind 的常驻å�¡ç‰‡ã€�它的æ�¢å¤�脚注ã€�它对出错æ�œç´¢ä¸Žå·²ç»“ç®—æ— å�¡ç‰‡ç»“果两者的 fallback bodyã€�它与摘è¦�行è¿�行状æ€�的一致ã€�替æ�¢æ ‡é¢˜ä¼˜å…ˆçº§ï¼Œä»¥å�Šä¸€ä¸ªç»„件在 `grep` 与 `glob` 两个键下的 keyed 注册;以å�Š details panel çš„ Output 段对两ç§� kind(å�«æ�¢å¤�脚注),对照é�žæ�œç´¢çš„压平形æ€�。`packages/client/ui-conversation/src/*` 在覆盖排除清å�•ä¸Šï¼Œå› æ¤è¯¥æ–‡ä»¶ä¸�å�— gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个å�‘出 `kind: 'matches'` çš„ `grep` turn(三个文件ã€�å��二行超过行内上é™�ã€�`truncated` 且带溢出æ�¢å¤�è„šæ³¨ï¼Œå› æ¤åœ¨ç»„装快照里å�Œæ—¶æ¼”练头/尾上é™�与æ�¢å¤�脚注)与一个å�‘出 `kind: 'paths'` çš„ `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` æœ�务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约è¦�求的组装输出检查:它通过 keyless fixture ä¼ è¾“å�¯åŠ¨çœŸå®žæž„å»ºçš„ `client.js` bundle,打开 fixture 会è¯�,并把 grep å�¡ç‰‡çš„组装形æ€�——kindã€�æˆªæ–æ‘˜è¦�ã€�头/尾切片å�Šå…¶å±•开控件——固定在 `apps/web/tests/snapshots/search-card/` ä¸‹ï¼Œå› æ¤ä¸€ä¸ªæ�Ÿå��çš„ SearchRow 注册或被丢弃的å�¡ç‰‡ä¼šè®©ä¸€ä¸ª golden 失败,而 built-boot smoke(按契约å�ªæµ‹å�¯åŠ¨ï¼‰æ— æ³•æ�•获它。
+
+## Related
+
+- [Search render intent —— grep 与 glob å�‘出结构化æ�œç´¢å�¡ç‰‡](2026-07-30-search-render-card.md) —— å�Žç«¯å¥‘约与它的两个生产者;本 note 是它指å��çš„ web 消费者å�Žç»ã€‚
+- [Web 终端å�¡ç‰‡](2026-07-28-web-terminal-card.md) —— 本 note 镜åƒ�的先例:工具的 render intent 通过一个 `ui-primitives` å�—ã€�一个 `contract/*-card-model.ts` 推导ã€�以å�Šå�Œæ ·çš„三个渲染点到达æµ�览器。
+- [å·¥å…·è°ƒç”¨å‘ˆçŽ°çš„æ ‡ç¾åŒ– render-intent è�”å�ˆ](../architecture/2026-07-02-tool-render-intent-union.md) —— ä¸¤å¼ å�¡ç‰‡éƒ½æ¶ˆè´¹çš„ `card` æ ‡ç¾è¯�汇。
diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml
new file mode 100644
index 0000000000..d1fb6cb6c7
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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-07-31-browser-derived-initial-locale.md
+2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816
+2026-07-31-browser-derived-initial-locale.zh.md: c013d24dcd3bb49d176eaddd42ff41dde320ff1f
diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
new file mode 100644
index 0000000000..0c49a6bbfe
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
@@ -0,0 +1,36 @@
+# Agent Note: The Settings language a fresh browser opens in comes from the browser
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-browser-derived-initial-locale.zh.md)
+
+## Problem
+
+The Settings Language row opened every first visit in Chinese: `LocaleService` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen.
+
+## Decision
+
+**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak.
+
+**Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
+
+**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
+
+**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question.
+
+**The browser e2e lane now pins the browser language, not just storage.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature.
+
+## Alternatives considered
+
+- **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading.
+- **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this".
+- **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it.
+- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
+- **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end.
+
+## Consequences
+
+- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
+- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen".
+- Tests that construct a `LocaleService` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all).
+- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged.
diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
new file mode 100644
index 0000000000..c013d24dcd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: 全新æµ�览器打开的设置è¯è¨€ç”±æµ�览器决定
+
+Status: implemented
+
+[English](2026-07-31-browser-derived-initial-locale.md) | 䏿–‡
+
+## Problem
+
+设置里的è¯è¨€è¡Œåœ¨æ¯�ä¸€æ¬¡é¦–è®¿æ—¶éƒ½ä»¥ä¸æ–‡å¼€åœºï¼š`LocaleService` 从 localStorage 读å�– `dsh.locale`,读ä¸�到就直接回è�½åˆ° `zh`。æµ�览器本已声明其使用者阅读哪些è¯è¨€â€”—`navigator.languages` 就是这份声明——而应用对æ¤è§†è€Œä¸�è§�,于是英文读者迎é�¢æ’žä¸Šä¸€ä¸ªä¸æ–‡äº§å“�ï¼Œè¿˜å¾—å…ˆæ‰¾åˆ°ä¸€è¡Œä¸æ–‡æ ‡ç¾çš„设置项æ‰�能脱身。回è�½å€¼å½“æ—¶å�Œæ—¶æ‰¿æ‹…两份è�Œè´£ï¼šæ—¢æ˜¯æ— 法解æž�出 locale 时的最å�Žå…œåº•,也是所有从未å�šè¿‡é€‰æ‹©çš„ç”¨æˆ·æ‹¿åˆ°çš„ç”æ¡ˆã€‚
+
+## Decision
+
+**åˆ�å§‹ locale ä¾�次ç»�三个æ�¥æº�è§£æž�:已æŒ�久化的å��好ã€�æµ�览器ã€�`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` ä¸çš„ `resolveInitialLocale()` 在æœ�åŠ¡æž„é€ æ—¶è¿�行,是这一顺åº�的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(å˜å‚¨é¡¹ç¼ºå¤±ã€�æ— æ³•è§£æž�或ä¸�å�¯è®¿é—®ï¼Œä¸€å¾‹è¯»ä½œ*没有å��好*),å�Žä¸€ä¸ªæ�¥æº�æ‰�有开å�£çš„æœºä¼šã€‚
+
+**æµ�览器匹é…�按主å�æ ‡ç¾è¿›è¡Œï¼Œä¸”é��历有åº�列表。** `detectBrowserLocale()` é��历 `[...(navigator.languages ?? []), navigator.language]`,返回主å�æ ‡ç¾å‘½ä¸å·²æ��ä¾› locale 的首个æ�¡ç›®ï¼Œå› æ¤ `zh-Hans-CN` 与 `zh-TW` å�Œå½’ `zh`ã€�`en-GB` å½’ `en`;而å�ªè¯·æ±‚本应用ä¸�æ��供的è¯è¨€ï¼ˆ`fr`ã€�`de`)的æµ�览器则什么都匹é…�ä¸�到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之å�Žï¼Œå¹¶å…œä½�那些 Navigator 上没有 `languages` 的宿主——DOM åº“æŠŠå®ƒæ ‡æ³¨ä¸ºå¿…ç„¶å˜åœ¨ï¼Œæ‰€ä»¥è¿™ä»½å®¹å¿�带一æ�¡çª„å�£å¾„ lint 例外,与 `localStorage` 守å�«è¡¨è¾¾çš„环境边界ä¸�ä¿¡ä»»å�Œæº�。
+
+**判定æµ�览器用的是 `window` 而é�ž `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身è¯è¨€ï¼ˆCI runner 上是 `en-US`ï¼‰ï¼Œå› æ¤ä»¥ `navigator` 把关会让 node å�¯åŠ¨å®¢æˆ·ç«¯æ ‘æ—¶è§£æž�æˆ� `en`,而é�žæ–‡æ¡£çº¦å®šçš„回è�½å€¼ã€‚以 `window` 把关å�¯ä½¿æ‰€æœ‰é�žæµ�览器è¿�行都å�œç•™åœ¨ `FALLBACK_LOCALE`。
+
+**显å¼�选择是永久的。** `setLocale` çš„æŒ�久化未作改动,且æŒ�ä¹…åŒ–å€¼æœ€å…ˆè¢«æŸ¥è¯¢ï¼Œå› æ¤é€‰è¿‡è¯è¨€çš„用户å�³ä¾¿åœ¨ä¸�å�Œæµ�览器é…�置或系统è¯è¨€ä¹‹é—´è¾—转也ä¿�留原选择。没有任何代ç �把探测到的 locale 写回å˜å‚¨ï¼šæŽ¢æµ‹åœ¨æ¯�次å�¯åŠ¨æ—¶é‡�新推导,对"用户是å�¦å�šè¿‡é€‰æ‹©"这一问题始终ä¸�å�¯è§�。
+
+**æµ�览器 e2e 车é�“现在钉ä½�æµ�览器è¯è¨€ï¼Œè€Œä¸�å�ªæ˜¯å˜å‚¨é¡¹ã€‚** æ–è¨€ä¸æ–‡æ–‡æ¡ˆçš„场景(`access-confirmation`ã€�`models-settings`ã€�`onboarding-deepseek-config`ã€�`settings-chrome`)以 `apps/web/tests/support.ts` çš„ `locale: ZH_BROWSER_LOCALE` 打开页é�¢ï¼›`newEnglishPage` ä»�ç„¶é’‰ `dsh.locale=en`,它ä¾�旧压过任何æµ�览器è¯è¨€ã€‚`settings-chrome.e2e.ts` 新增一个场景:å�¦å¼€ä¸€ä¸ªå˜å‚¨é¡¹ä¸ºç©ºçš„ `en-US` 页é�¢ï¼Œæ–言设置界é�¢ä»¥è‹±æ–‡å‘ˆçŽ°â€”â€”è¿™æ˜¯æœ¬åŠŸèƒ½åœ¨ç»„è£…å�Žåº”用ä¸çš„è¯�æ�®ã€‚
+
+## Alternatives considered
+
+- **`Intl.DateTimeFormat().resolvedOptions().locale` 或å�•读 `navigator.language`**:两者都把用户的有åº�å��好列表塌缩æˆ�ä¸€ä¸ªæ ‡ç¾ï¼ŒäºŽæ˜¯ `['de', 'en', 'zh']` 的读者拿到的是 zh 而é�ž en。列表æ�°æ�°æ˜¯æµ�览器这份声明里最值得读的部分。
+- **首次å�¯åЍå�³æŒ�久化探测结果**:那会把探测å�˜æˆ�一次性事件,让一次陈旧的首访凌驾于æ¤å�Žæ”¹å�˜çš„æµ�览器è¯è¨€ä¹‹ä¸Šï¼Œä¹Ÿæ‘§æ¯�了整个解æž�顺åº�所ä¾�赖的区分——å˜å‚¨å€¼å°†ä¸�å†�æ„�味ç�€"用户选了它"。
+- **完整的 BCP 47 å��商(`Intl.LocaleMatcher` å¼�查找ã€�åœ°åŒºä¸Žæ–‡å—æ�ƒé‡�)**:在å�ªæ��供两个è¯è¨€äº’异的 locale 时,主å�æ ‡ç¾åŒ¹é…�就是æ£ç¡®ç”案的全部;å��商层å�ªä¼šå¸¦æ�¥æ— 行为支撑ã€�ä¹Ÿæ— ä»Žæµ‹è¯•çš„è¡¨é�¢ç§¯ã€‚
+- **为默认 locale å¢žåŠ ä¸€ä¸ª cordis config key**:æ¤å¤„éƒ¨ç½²ä¹‹é—´å¹¶æ— å·®å¼‚â€”â€”å›žè�½å€¼æ˜¯äº§å“�对"完全没有信å�·"ç»™å‡ºçš„ç”æ¡ˆï¼Œä¸�是旋钮。仓库ç–略把 `Config` å—æ®µç•™ç»™æœ‰å½“å‰�消费者ã€�且éš�部署å�˜åŒ–的选择。
+- **让 e2e 车é�“çš„ä¸æ–‡åœºæ™¯ç»§ç»é’‰å˜å‚¨é¡¹ï¼ˆ`dsh.locale=zh`)**:那会让套件ä¿�æŒ�绿色,å�´æŠ¹æŽ‰æµ�览器推导路径在组装å�Žåº”用ä¸å”¯ä¸€çš„è¿�行处;改钉æµ�览器è¯è¨€æ‰�能端到端地演练新的解æž�过程。
+
+## Consequences
+
+- æ�¥è‡ªè‹±æ–‡æµ�览器的首访è�½åœ¨è‹±æ–‡ç•Œé�¢ï¼Œè€Œè¯è¨€è¡Œä¾�然呈现å�Œæ ·ä¸¤ä¸ªä»¥è‡ªèº«è¯è¨€è‡ªè¿°çš„选项,两个方å�‘的脱身通é�“都未改å�˜ã€‚
+- `FALLBACK_LOCALE` 收窄回它真æ£çš„è�Œè´£â€”—å—典回è�½ä¸Žæ— ä¿¡å�·æ—¶çš„ç”æ¡ˆâ€”—ä¸�å†�å…¼è�Œå……当"用户尚未选择"。
+- 在 jsdom ä¸‹æž„é€ `LocaleService` 的测试现在ä¾�赖环境的 `navigator`:æ–言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其æµ�览器,今å�Žä»»ä½•æ–言默认值的用例å�Œæ ·å¦‚æ¤ã€‚æœ¬åŒ…è‡ªå·±çš„ç”¨ä¾‹ç›´æŽ¥ç»™å…¨å±€æ‰“æ¡©ï¼Œå› ä¸ºå®ƒä»¬éœ€è¦�该 helper 刻æ„�ä¸�表达的形状(`languages` 缺失ã€�列表与 `language` 解耦ã€�完全没有 `window`)。
+- 探测的代价是æ¯�次æœ�åŠ¡æž„é€ é��历一次数组,且ä¸�写å˜å‚¨ï¼Œå› æ¤å�¯åŠ¨è¡Œä¸ºä¸ŽæŒ�久化状æ€�é�¢å�‡æ— å�˜åŒ–。
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
new file mode 100644
index 0000000000..99ea335b59
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.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-07-31-gui-full-access-confirmation.md
+2026-07-31-gui-full-access-confirmation.md: ca89ed23fb1b5c6ea438dd22fdf20d2b82af754c
+2026-07-31-gui-full-access-confirmation.zh.md: 0f487e41b544718bdf38de611a1ab2d59c44b063
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
new file mode 100644
index 0000000000..ca89ed23fb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
@@ -0,0 +1,31 @@
+# Agent Note: GUI Full access risk confirmation
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-gui-full-access-confirmation.zh.md)
+
+## Problem
+
+Switching the web client to `danger-full-access` was a single click on a permission picker, with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
+
+## Decision
+
+**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
+
+- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
+- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
+- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
+- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
+- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
+
+## Alternatives considered
+
+**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
+
+**One shared locale namespace for every surface's safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, while the Settings warning names a different future-session lifetime. Each bundle owns its copy, and ui-permission keeps the popup and Settings dictionaries separate rather than importing across bundle boundaries.
+
+**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
+
+## Consequences
+
+Every visible GUI path into Full access requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the shared dialog through their owning state machine or attach a `confirmation` payload to the popup path. Acceptance: the composer flow's gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the default-setting gate in `permission-row.spec.tsx`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled Web replays.
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
new file mode 100644
index 0000000000..0f487e41b5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: GUI Full access 风险确认
+
+Status: implemented
+
+[English](2026-07-31-gui-full-access-confirmation.md) | 䏿–‡
+
+## Problem
+
+在 Web 客户端的æ�ƒé™�选择器ä¸åˆ‡æ�¢åˆ° `danger-full-access` å�ªéœ€ä¸€æ¬¡ç‚¹å‡»ï¼Œä¸”预设以 Title Case 机器å�� `Danger Full Access` 展示。Full access 会å‡�少确认æ¥éª¤ï¼Œå…�许智能体执行æ•�感æ“�作ã€�修改文件或è¿�行外部命令,误点å�³åœ¨æ¯«æ— 刻æ„�确认环节的情况下å�¯ç”¨äº†æœ€å�±é™©çš„预设。
+
+## Decision
+
+**æ¯�个æ�ƒé™�选择器都把 `danger-full-access` 关进共享的页é�¢å†… `RiskConfirmation` 对è¯�框:å�¯ç”¨æŒ‰é’®åœ¨ç”¨æˆ·å‹¾é€‰æ˜Žç¡®çš„风险确认å¤�选框å‰�ä¿�æŒ�ç¦�用;预设以产å“�æ ‡ç¾ `Full access` 展示;所有å�–消路径都ä¸�作任何æ��交。**
+
+- `RiskConfirmation`(ui-primitives)是å�—控的 Modal 组å�ˆï¼šæ ‡é¢˜ã€�说明ã€�确认å¤�选框ã€�å�–消,以å�Š `acknowledged` 勾选å‰�ç¦�用的确认按钮。它始终是页é�¢å†…对è¯�框——Modal portal 到本文档 body,ç»�ä¸�打开å�¯èƒ½è�½åœ¨å�¦ä¸€å�—显示器上的原生或独立æµ�览器窗å�£ã€‚`Modal` 新增 `contentClassName` 座ä½�,令è¦ç¤ºæ£æ–‡åœ¨å�—é™�的移动端ï¼�横å±�视å�£å†…滚动,动作行ä¿�æŒ�固定。
+- 编辑器 chip(ui-conversation çš„ `PermissionSelect`)在 `/permission` æ��交å‰�拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状æ€�打开对è¯�框,确认å�Žç»�与其他选择完全相å�Œçš„æ³¨å…¥ `command` 通é�“æ��交 `/permission danger-full-access`ï¼›å�–消ã€�Escapeã€�å…³é—与é�®ç½©ç‚¹å‡»å�‡ä¿�æŒ�当å‰�预设ä¸�å�˜å¹¶é‡�ç½®å¤�选框。会è¯�é”�定时确认自行撤销(`locked`ï¼�å€¼ç¼ºå¸ effect),切æ�¢ä»»åŠ¡æ—¶éš� `key={sessionId}` é‡�挂载而é‡�置。文案ç»�æ ‡å‡† `conversation` locale 座ä½�以 `access.confirm.*` 键供给。
+- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数�而�第二套对�框实现完�把关:`SelectOption` 新增�选的 `confirmation` 载�,popup 控制器拥有 `confirming`/`acknowledged` 状��移,`PopupSelectView` 在门控选项未决期间把选择����一个 `RiskConfirmation`。
+- 「通用ã€�设置ä¸çš„「æ�ƒé™�ã€�行在把 Full access æŒ�久化为å�Žç»ä¼šè¯�的默认值å‰�,也使用å�Œä¸€ä¸ªå�—控 `RiskConfirmation`。è¦ç¤ºä¼šæ˜Žç¡®è¯´æ˜Žè¯¥è®¾ç½®å�ªå½±å“�å�Žç»ä¼šè¯�ï¼›å�–消ã€�Escapeã€�å…³é—与点击é�®ç½©å�‡ä¸�会改动已å˜é»˜è®¤å€¼ã€‚
+- `Full access` 在æ¯�个选择器ä¸éƒ½æœ‰æ„�覆盖 kebab 转 Title Case 的显示å�˜æ�¢ï¼›å‘½ä»¤ä¸Ž Settings 写入在 wire 上ä¿�留机器å��,æ¯�份è¦ç¤ºæ£æ–‡éƒ½ä¿�æŒ�ä¸è‹±æ–‡ locale 感知。
+
+## Alternatives considered
+
+**原生ï¼�æ“�作系统或独立窗å�£ç¡®è®¤ã€‚** 已拒:对è¯�框必须留在当å‰� WebUI 窗å�£å†…;第二个窗å�£å�¯èƒ½å‡ºçŽ°åœ¨å�¦ä¸€å�—显示器上,使决ç–脱离其守护的页é�¢çжæ€�。
+
+**æ¯�个é�¢çš„安全文案共享一个 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation å�¯ç‹¬ç«‹åŠ è½½ï¼Œè€Œ Settings è¦ç¤ºè¯´æ˜Žçš„æ˜¯å�¦ä¸€ç§�å�ªå½±å“�å�Žç»ä¼šè¯�的生效周期。æ¯�个 bundle å�„自拥有文案,ui-permission 也将 popup 与 Settings è¯�典分开,而é�žè·¨ bundle 边界 import。
+
+**在 hostï¼�æ�ƒé™�å�Žç«¯æŠŠå…³ã€‚** 设计上å�³å‡ºç•Œï¼šæœ¬å�˜æ›´å�ªæ¶‰æµ�览器客户端确认æµ�ï¼›å�Žç«¯æ�ƒé™�è¯ä¹‰ã€�默认值与更安全预设的一键行为å�‡ä¸�å�˜ã€‚
+
+## Consequences
+
+进入 Full access çš„æ¯�æ�¡å�¯è§� GUI 路径现在都è¦�求刻æ„�且知情的确认,代价是真想å�¯ç”¨è¯¥é¢„设的用户多一æ¥å¯¹è¯�框。新的选择器通过å�„自拥有的状æ€�机å¤�用共享对è¯�框,或在 popup 路径挂 `confirmation` è½½è�·ã€‚验收:`input-bar.spec.tsx` ä¸ç¼–辑器æµ�的门控用例ã€�`popup-view.spec.tsx` 与 `popup.spec.ts` çš„ popup é—¨ã€�`permission-row.spec.tsx` 的默认设置门控ã€�`atoms.spec.tsx` çš„ Modal/RiskConfirmation 契约,以å�Šç»„装æ€� Web 回放。
diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml
new file mode 100644
index 0000000000..d4f7f72fe2
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.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-07-31-hover-card-click-copy.md
+2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2
+2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904
diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md
new file mode 100644
index 0000000000..c87734fe32
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md
@@ -0,0 +1,29 @@
+# Agent Note: Hover cards copy their primary value on activation
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-hover-card-click-copy.zh.md)
+
+## Problem
+
+Workspace and Session rows clip the two values their hover cards expose in full: the Workspace directory path and Session title. The [reachable card](../bug-fix/2026-07-30-hover-popup-pointer-grace.md) permits text selection, but selecting and copying a single known value is a needlessly precise gesture, and the card gives no confirmation that the clipboard accepted it.
+
+## Decision
+
+`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; its accessible name combines the localized action prefix with the exact value, it writes that value through the shared clipboard helper, and it replaces its content with the success label for up to one second only after the host accepts the write. The feedback retains the pre-copy card height and clears with the card. Without `copyText`, the atom retains its read/select-only behavior.
+
+The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a non-blank Session card passes the full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder, not session content. The browser's locale seat supplies `Copy`/`�制` and the success state `Copied`/`已�制`.
+
+Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; a completed non-collapsed selection intersecting the card suppresses pointer-click activation, while a plain click or button key activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success.
+
+## Alternatives considered
+
+**Copy the card's rendered `textContent`.** That would concatenate the primary value with creation time or running status, making the clipboard payload depend on presentation and localization.
+
+**Implement clipboard state in both Workspace card bodies.** The two consumers would duplicate host fallback, keyboard behavior, timer ownership, and success rendering even though the card owns the activation surface.
+
+**Change the common Chinese `copied` label from `�制�功` to `已�制`.** That would alter every existing copy control to satisfy one card interaction. The Workspace dictionary owns the card-specific wording instead.
+
+## Consequences
+
+Both non-placeholder hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears copied state on close, ignores completion after close or unmount, and never reports a rejected write as success. Focused component coverage pins pointer selection precedence, activation, failure, feedback geometry and expiry, and cleanup, while the real-browser Workspace scenario verifies the English label, stable feedback height, and browser clipboard.
diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md
new file mode 100644
index 0000000000..a57b5238b0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md
@@ -0,0 +1,29 @@
+# Agent Note(agent 决ç–记录):悬浮å�¡ç‰‡æ¿€æ´»æ—¶å¤�制主è¦�值
+
+Status: implemented
+
+[English](2026-07-31-hover-card-click-copy.md) | 䏿–‡
+
+## 问题
+
+Workspace 与 Session 行会截æ–对应悬浮å�¡ç‰‡å®Œæ•´å±•示的两个值:Workspace 目录路径和 Session æ ‡é¢˜ã€‚è¿™å¼ [å�¯æŠµè¾¾çš„å�¡ç‰‡](../bug-fix/2026-07-30-hover-popup-pointer-grace.md)支æŒ�文本选择,但å¤�制å�•个已知值ä»�需精确选择,æ“�作没有必è¦�地ç¹�ç��ï¼›å�¡ç‰‡ä¹Ÿä¸�会确认剪贴æ�¿æ˜¯å�¦æŽ¥å�—了写入。
+
+## 决ç–
+
+`HoverCard` 接收å�¯é€‰çš„ `copyText`,以å�Š `copyLabel` å’Œ `copiedLabel`ã€‚ä¼ å…¥ `copyText` å�Žï¼Œæ•´ä¸ªå�¡ç‰‡éƒ½ä¼šä¸ºæŒ‡é’ˆä¸Žé”®ç›˜æ¿€æ´»æ��供按钮è¯ä¹‰ï¼›å…¶æ— éšœç¢�å��称由本地化æ“�作å‰�缀和原值组æˆ�,å�¡ç‰‡é€šè¿‡å…±äº«å‰ªè´´æ�¿è¾…助函数写入该值,并且å�ªæœ‰å®¿ä¸»æŽ¥å�—写入å�Žï¼Œæ‰�会用æˆ�åŠŸæ ‡ç¾æ›¿æ�¢å†…容最长一秒。å��馈ä¿�æŒ�å¤�制å‰�çš„å�¡ç‰‡é«˜åº¦ï¼Œå¹¶ä¼šéš�å�¡ç‰‡å…³é—一å�Œæ¸…é™¤ã€‚æœªä¼ å…¥ `copyText` 时,该原å�组件维æŒ�å�ªè¯»ä¸”å�¯é€‰æ‹©æ–‡æœ¬çš„行为。
+
+Workspace æµ�览器选择å¤�制载è�·ï¼Œä¸�è®©åŸºç¡€ç»„ä»¶ä»Žæ¸²æŸ“æ–‡æœ¬ä¸æŽ¨æ–:Workspace å�¡ç‰‡ä¼ 入完整目录路径,é�žç©ºç™½ Session å�¡ç‰‡ä¼ å…¥å®Œæ•´æ˜¾ç¤ºæ ‡é¢˜ã€‚ä¸´æ—¶çš„ç©ºç™½ã€Œæ–°ä¼šè¯�ã€�å�¡ç‰‡ä¿�æŒ�å�ªè¯»ï¼Œå› ä¸ºå…¶æœ¬åœ°åŒ–æ ‡ç¾æ˜¯å� ä½�文案,并é�žä¼šè¯�内容。æµ�览器的 locale å¸ä½�æ��ä¾› `Copy`ï¼�`å¤�制`,æˆ�功状æ€�则使用 `Copied`ï¼�`å·²å¤�制`。
+
+按下与激活ä»�是两份独立契约。å�¡ç‰‡å†…å�‘生指针按下时,å�¡ç‰‡ä¿�æŒ�挂载,以便用户开始选择文本;文本选择完æˆ�å�Žï¼Œè‹¥é�žæŠ˜å� 选区与å�¡ç‰‡ç›¸äº¤ï¼Œå°±ä¼šé˜»æ¢æŒ‡é’ˆç‚¹å‡»æ¿€æ´»ï¼Œè€Œæ™®é€šç‚¹å‡»æˆ–按钮激活键会激活å¤�制。锚点区域内å�‘生指针按下时,å�¡ç‰‡ä»�会立å�³æ¶ˆå¤±ï¼›å‰ªè´´æ�¿æ‹’ç»�写入时,å�¡ç‰‡ç»§ç»æ˜¾ç¤ºåŽŸå†…å®¹ï¼Œä¸�会声称å¤�制æˆ�功。
+
+## 备选方案
+
+**�制�片渲染�的 `textContent`。** 这会把主�值与创建时间或�行状�拼接起�,使剪贴�载��赖表现形�和本地化结果。
+
+**在两个 Workspace å�¡ç‰‡ä¸»ä½“ä¸åˆ†åˆ«å®žçŽ°å‰ªè´´æ�¿çжæ€�。** 两个消费方会é‡�å¤�实现宿主回退ã€�键盘行为ã€�计时器所有æ�ƒå’Œæˆ�功状æ€�渲染,尽管激活表层由å�¡ç‰‡æŒ�有。
+
+**å°†é€šç”¨ä¸æ–‡ `copied` æ ‡ç¾ä»Ž `å¤�制æˆ�功` 改为 `å·²å¤�制`。** è¿™æ ·ä¼šä¸ºäº†æ»¡è¶³ä¸€ç§�å�¡ç‰‡äº¤äº’而改å�˜æ‰€æœ‰çŽ°æœ‰å¤�制控件。å�¡ç‰‡ä¸“用文案应由 Workspace å—å…¸æŒ�有。
+
+## �果
+
+两ç§�é�žå� ä½�悬浮å�¡ç‰‡éƒ½èŽ·å¾—ç›¸å�Œçš„点击与键盘æ“�作能力,å�Œæ—¶ä¿�留由消费方决定载è�·çš„è¯ä¹‰å’Œæœ¬åœ°åŒ–å��馈。通用原å�ç»„ä»¶å¢žåŠ ä¸€æ�¡å�¯é€‰è¡Œä¸ºè·¯å¾„和一个一秒计时器;å�¡ç‰‡å…³é—时会清除已å¤�制状æ€�ï¼Œå…³é—æˆ–å�¸è½½å�Žåˆ°è¾¾çš„完æˆ�结果会被忽略,写入被拒ç»�æ—¶ç»�ä¸�会报告æˆ�功。è�šç„¦ç»„件测试会固定指针选择文本的优先级ã€�激活ã€�失败ã€�å��馈尺寸与到期清除以å�Šæ¸…ç�†è¡Œä¸ºï¼ŒçœŸå®žæµ�览器ä¸çš„ Workspace 场景则验è¯�è‹±æ–‡æ ‡ç¾ã€�å��馈期间高度稳定和æµ�览器剪贴æ�¿ã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml
new file mode 100644
index 0000000000..8d4467858b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
+2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3
+2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
new file mode 100644
index 0000000000..35812b53d0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
@@ -0,0 +1,35 @@
+# Agent Note: Permission Settings default for new sessions
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-permission-default-for-new-sessions.zh.md)
+
+## Problem
+
+The Web General-settings page displayed Permission as a disabled skeleton even though `dsh-permission` already owned the preset table and current-session switch path. The Settings seam could persist a plugin-owned value, but the Web settings API exposed only configurable LLM-provider namespaces. More importantly, treating a user preference as a live global permission would make an existing session's execution policy change outside its durable log.
+
+## Decision
+
+`dsh-permission` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
+
+The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
+
+The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
+
+ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`.
+
+## Consequences
+
+Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
+
+The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
+
+## Alternatives considered
+
+**Apply the Settings value live to every session.** Rejected because execution policy would change without a session event and replay could not reconstruct which permission governed an earlier tool call.
+
+**Record only `permission/preset` on creation.** Rejected because sandbox and approval are independently owned whole-value knobs; pinning all three facts keeps their consumers independent of future composition-default changes.
+
+**Expose all Settings registrations, or add a generic `local-client` declaration.** Rejected for this change because it expands a security boundary and the Settings contract beyond the one requested preference. The explicit `permission` allowlist entry is sufficient and leaves future namespaces to make their own exposure decision.
+
+**Apply the latest default while resuming a seeded session.** Rejected because resume must preserve the session's prior effective execution policy; missing legacy facts are materialized from that policy instead.
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
new file mode 100644
index 0000000000..a75deaec32
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 新会�的�� Settings 默认值
+
+Status: implemented
+
+[English](2026-07-31-permission-default-for-new-sessions.md) | 䏿–‡
+
+## 问题
+
+Web「通用ã€�设置页将「æ�ƒé™�ã€�显示为ç¦�用的骨架控件,尽管 `dsh-permission` å·²ç»�拥有 preset 表和当å‰�会è¯�的切æ�¢è·¯å¾„。Settings seam å�¯ä»¥æŒ�久化由æ�’件拥有的值,但 Web Settings API å�ªæš´éœ²å�¯é…�ç½® LLM æ��供方的 namespace。更é‡�è¦�的是,如果把用户å��好当æˆ�实时生效的全局æ�ƒé™�,现有会è¯�的执行ç–略就会在其æŒ�久日志之外å�‘生å�˜åŒ–。
+
+## 决ç–
+
+`dsh-permission` 拥有一个 `permission` Settings namespace,其ä¸å�ªæœ‰ `defaultPreset` å—æ®µã€‚它的基础值是 `Config.defaultPreset`ï¼›çœ�略该é…�置时,则使用与组å�ˆå�Žçš„æ²™ç®±å’Œå®¡æ‰¹é»˜è®¤å€¼åŒ¹é…�çš„ preset。schema çš„ enum 从已é…�置的 preset è¡¨æ´¾ç”Ÿï¼Œå› æ¤ Settings æ—¢èƒ½æ ¡éªŒå·²å˜å‚¨çš„值,Web 客户端也能å�‘现部署ä¸çš„å®žé™…é€‰é¡¹ï¼Œè€Œæ— éœ€é‡�å¤�定义。
+
+æœ�务会在 `session/created` æ—¶å�Œæ¥è¯»å�–当å‰� Settings 值。真æ£çš„æ–°ä¼šè¯�会收到三个显å¼�事件:`permission/preset`ã€�`sandbox/mode` å’Œ `approval/policy`。这些事实将创建时选ä¸çš„æ�ƒé™�固定下æ�¥ï¼Œå› æ¤å�Žç» Settings å�˜æ›´å�ªå½±å“�之å�Žçš„会è¯�。带 seed 或å�ªå®Œæˆ�部分åˆ�始化的会è¯�会ä¿�留其有效调节项,å�ªè¡¥é½�缺失的事实;æ�¢å¤�æ—¶ç»�ä¸�会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` æ ‡è®°æ˜¾å¼�ä¸ºç©ºçš„æž„é€ å™¨ seedï¼Œå› æ¤ä¸�能把空的æŒ�久化日志误认为新会è¯�。
+
+现有 `/permission` 命令和 `permissions` 投影ä»�是当å‰�会è¯�çš„æ“�作路径。æµ�览器æ�’件现在å�‘ `settings.general.item` 贡献「æ�ƒé™�ã€�行,从脱æ•�å�Žçš„ Settings æ��述符读å�–动æ€� enum,并å�ªé€šè¿‡ç»�过 revision æ ¡éªŒçš„ `settings.mutate` 写入 `defaultPreset`。该行通过 slot çš„ `hooks` æ ¼æ³¨å…¥ observable,而ä¸�是绑定渲染器专用钩å�ï¼›æ�ƒé™�æœ�务挂载时会é��åŽ†å¹¶å›ºå®šæ‰€æœ‰å·²å˜æ´»ä¼šè¯�ï¼Œå› æ¤ HMRï¼ˆçƒæ¨¡å�—替æ�¢ï¼‰ä¸�会é�—留未固定的会è¯�ã€‚æ— å½’å±žçš„ã€Œé€šç”¨ã€�设置包ä¸�贡献任何å� ä½�行。
+
+ApiProxy 在å�¯é…�ç½®æ��供方 namespace 之外,将 `permission` 显å¼�åŠ å…¥ Web Settings allowlist。这是局部的边界决ç–,而ä¸�æ˜¯é€šç”¨æ³¨å†Œæ ‡å¿—æˆ– `local-client` 访问模型:注册其他 Settings namespace ä»�ä¸�会将其暴露。æ�ƒé™�å�˜æ›´ä¼šå�‘出 `host/settings-changed`,但ä¸�会å�‘出 `host/models-changed`。
+
+## �果
+
+在 Settings 䏿›´æ”¹ã€Œæ�ƒé™�ã€�会立å�³æ›´æ–° `settings.yaml` 和选择器,但ä¸�会改å�˜å·²æ‰“开的会è¯�。之å�Žçš„æ¯�个会è¯�都å�¯ä»¥ä»Žä¸‰ä¸ªå·²å›ºå®šçš„æ�ƒé™�事实ä¸é‡�建,å�³ä½¿ç”¨æˆ·å†�次更改默认值或进程é‡�å�¯ä¹Ÿä¸�å�—å½±å“�。如果部署ä¸ç»„å�ˆå�Žçš„æ²™ç®±å’Œå®¡æ‰¹é»˜è®¤å€¼ä¸Žä»»ä½• preset 都ä¸�匹é…�,则必须显å¼�é…�ç½® `defaultPreset`。
+
+组装å�Žçš„ Web 快照现在包å�«åŠŸèƒ½å®Œæ•´çš„ã€Œæ�ƒé™�ã€�é€‰æ‹©å™¨ã€‚å…¶æ— å¯†é’¥æµ�览器场景会写入 `read-only`,验è¯�现有的 `danger-full-access` 会è¯�ä¿�æŒ�ä¸�å�˜ï¼Œå¹¶éªŒè¯�éš�å�Žåˆ›å»ºçš„会è¯�以 read-only 事件三元组å�¯åŠ¨ã€‚
+
+## 曾考虑的替代方案
+
+**å°† Settings 值实时应用于æ¯�个会è¯�。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºæ‰§è¡Œç–略会在没有会è¯�事件的情况下改å�˜ï¼Œé‡�æ”¾ä¹Ÿæ— æ³•é‡�建先å‰�工具调用采用了哪ç§�æ�ƒé™�。
+
+**创建时å�ªè®°å½• `permission/preset`。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºæ²™ç®±å’Œå®¡æ‰¹æ˜¯ç”±ä¸�å�Œç»„件独立拥有的全é‡�值调节项;固定全部三个事实,å�¯ä»¥è®©å…¶æ¶ˆè´¹æ–¹ä¸�ä¾�赖未æ�¥çš„组å�ˆé»˜è®¤å€¼å�˜åŒ–。
+
+**暴露所有 Settings æ³¨å†Œï¼Œæˆ–å¢žåŠ é€šç”¨çš„ `local-client` 声明。** 本次å�˜æ›´ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºè¿™ä¼šæ‰©å¤§å®‰å…¨è¾¹ç•Œï¼Œå¹¶ä½¿ Settings 契约超出所请求的å�•项å��好。显å¼�åŠ å…¥ `permission` allowlist 已足够,未æ�¥çš„ namespace å�¯ä»¥å�„自决定是å�¦æš´éœ²ã€‚
+
+**æ�¢å¤�带 seed 的会è¯�时应用最新默认值。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºæ�¢å¤�æ“�作必须ä¿�留会è¯�å…ˆå‰�的有效执行ç–略;缺失的旧版事实应从该ç–ç•¥ä¸è¡¥é½�。
diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml
new file mode 100644
index 0000000000..cca8f8a597
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.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-07-31-session-archive-global-set.md
+2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8
+2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045
diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md
new file mode 100644
index 0000000000..fab99a405a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md
@@ -0,0 +1,33 @@
+# Agent Note: Session archive (registry-global set)
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-session-archive-global-set.zh.md)
+
+## Problem
+
+The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is **archive**, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace entity, so a per-workspace field cannot carry it.
+
+## Decision
+
+**The archive set is a new field on the workspace domain's global singleton (`workspaceDomainState.archivedSessionIds`), layered over workspace accounting; display filtering converges entirely in the client's `tree.ts` derivation layer; the wire surface uses the full-snapshot posture.**
+
+- Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant.
+- Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set.
+- RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code.
+- Client runtime: `WorkspaceListState.archivedSessionIds` (a `readonly SessionId[]` in Host order, reference replaced only on membership change — public snapshot state stays in the store engine's plain-data vocabulary since immer drafts reject Sets without the MapSet plugin; membership lookups build a transient Set in the derivation, the expandedProjects pattern); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline.
+- UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source.
+
+## Alternatives considered
+
+**Per-workspace archivedSessionIds (the original phrasing).** Rejected: Ungrouped sessions have no home; the user switched to global.
+
+**An archived flag on SessionSummary (session.list layer).** Rejected: it joins a workspace-domain fact into the sessions-domain projection, summaries have no incremental frame so a separate notification would still be needed — cross-domain coupling outweighs the saving.
+
+**Host-side filtering in `workspaceView`/the `sessionIds` getter.** Rejected: archiving ≠changing accounting, and filtering the projection muddles the two concepts; a future restore surface also needs the client to see full accounting.
+
+**Incremental frames (single archived/removed rows).** Rejected: the set is tiny and changes rarely; full snapshots spare the client merge logic and dedup state and match the existing workspace-changed posture.
+
+## Consequences
+
+Archived sessions have no viewing or unarchive surface yet (this iteration's scope; recorded as a README Known Limitation); data and accounting slots stay intact, so a future restore is one UI surface plus one inverse RPC. The `workspace.list` response shape change is a pre-release direct edit (no compatibility layer). The workspace-management e2e pins the full chain (archive → row disappears → still hidden after reload, log still present); domain tests pin idempotence, unknown-id rejection, restart recovery, and the pre-field media default upgrade.
diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md
new file mode 100644
index 0000000000..e33f3b5272
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: Session 归档(注册表级全局集�)
+
+状�:implemented
+
+[English](2026-07-31-session-archive-global-set.md) | 䏿–‡
+
+## 问题
+
+Sidebar workspace æµ�览区的 session 行è�œå�•里,「Delete sessionã€�一直是纯视觉å� ä½�ï¼ˆæ— handler)。产å“�å�£å¾„定为**å½’æ¡£**而é�žåˆ 除:session 日志与 workspace 记账都ä¸�动,å�ªæŠŠè¯¥ session 从所有分组视图(workspace 分组ã€�Ungroupedã€�æ�œç´¢ã€�平铺列表)里éš�è—�。归档记录需è¦�一个è�½ç‚¹ï¼šUngrouped çš„ session ä¸�属于任何 workspace 实体,per-workspace å—æ®µæ”¾ä¸�下它。
+
+## 决ç–
+
+**归档集å�ˆæ˜¯ workspace domain 全局å�•例(`workspaceDomainState.archivedSessionIds`ï¼‰ä¸Šçš„ä¸€ä¸ªæ–°å—æ®µï¼Œè¦†ç›–在 workspace 记账之上;显示过滤全部收敛在 client çš„ `tree.ts` 派生层;wire é�¢èµ°å…¨å¿«ç…§å§¿æ€�。**
+
+- å˜å‚¨ï¼š`archivedSessionIds: z.array(sessionId).default([])`,domain version ä¿�æŒ� 2——纯增é‡�å—æ®µï¼Œæ—§ä»‹è´¨ç»� schema default è§£æž�为空集å�ˆï¼Œæ— è¿�移代ç �。被归档的 session ä¿�留其 `sessionIds` å¸ä½�(未æ�¥å�–消归档æ�¢å¤�原ä½�ç½®ï¼‰ï¼Œå› æ¤ä¸Žã€Œä¸€ä¸ª session å�ªè¢«ä¸€ä¸ª workspace è®°è´¦ã€�ä¸�å�˜å¼�é›¶çº ç¼ ã€‚
+- Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与�久化都查�到)抛 `WorkspaceUnknownSessionError`;已归档 id �写盘��事件。`archivedSessionIds` getter 暴露�读集�。
+- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应ç”完整更新å�Žé›†å�ˆï¼‰ï¼›`workspace.list` å“�应æ�ºå¸¦é›†å�ˆä½œä¸ºé‡�连基线;新 host 帧 `host/archived-sessions-changed` 在æ¯�次æŒ�ä¹…å�˜æ›´å�ŽæŽ¨å®Œæ•´å¿«ç…§ï¼ˆä¸Ž `host/workspace-changed` å�Œå§¿æ€�,从 `domain/changed` çš„ global put 分支比对推帧)。未知 session å¤�用错误ç � `session-not-found`。
+- client runtime:`WorkspaceListState.archivedSessionIds`(按 Host 顺åº�çš„ `readonly SessionId[]`,æˆ�员ä¸�å�˜ä¸�æ�¢å¼•用——公有快照状æ€�ä¿�æŒ� store 引擎的纯数æ�®è¯�汇:immer draft ä¸�å¼€ MapSet æ�’ä»¶å°±ä¸�接å�— Setï¼›membership 查询在派生函数内自建临时 Set,与 expandedProjects å�Œæ¬¾ï¼‰ï¼›list 基线ã€�unary 回声ã€�changed 帧三路都整体替æ�¢å®‰è£…。投影层在当å‰� selection è�½å…¥å½’档集å�ˆæ—¶ç»Ÿä¸€æ¸…空回 New Session 视图(用户æ‹�æ�¿ï¼šå½’档当å‰�打开的 session 主视图回 hero)——一æ�¡è§„则å�Œæ—¶è¦†ç›–本地 unary 回声ã€�å…¶ä»–æ ‡ç¾é¡µçš„ changed 帧ã€�以å�Šé‡�连基线æ�¢å¤�出一个离线期间被归档的 selection;帧/回声è�½åœ¨ in-flight `workspace.list` 期间时还会å±�蔽旧基线对新集å�ˆçš„回滚。
+- UI:è�œå�•项 `delete`(visual-only)改为 `archive`(label「Archive sessionã€�,é�ž danger æ ·å¼�ï¼Œæ— ç¡®è®¤å¯¹è¯�框——é�žç ´å��性æ“�作,误触å�Žæžœå�ªæ˜¯åˆ—表éš�è—�);过滤实现为 `tree.ts` çš„ `sessionVisible` 判æ�®åŠ ä¸€æ¡£ï¼Œ`deriveGroups`/`deriveFlat` å¢žåŠ `archived` 集å�ˆå…¥å�‚,四个视图(分组循环ã€�stray 兜底ã€�æ�œç´¢ã€�平铺)å�Œæº�生效。
+
+## 已考虑的替代方案
+
+**per-workspace archivedSessionIds(最åˆ�表述)。** å�¦å†³ï¼šUngrouped session æ— è�½ç‚¹ï¼›ç”¨æˆ·æ”¹å�£å…¨å±€ã€‚
+
+**SessionSummary 打 archived æ ‡ï¼ˆsession.list 层)。** å�¦å†³ï¼šè¦�把 workspace domain 事实 join è¿› sessions domain 投影,summary æ— å¢žé‡�帧还得å�¦å�‘通知,跨域耦å�ˆå¤§äºŽæ”¶ç›Šã€‚
+
+**host 侧在 `workspaceView`/`sessionIds` getter 过滤。** �决:归档 ≠改记账,投影过滤会把两个概念�浑;未���入�也需� client 拿到全�记账。
+
+**增�帧(archived/removed ��)。** �决:集�����更频率低,全快照�去 client 侧�并逻辑与去�状�,与 workspace-changed 现有姿�一致。
+
+## �果
+
+å½’æ¡£å�Ž UI æ— æŸ¥çœ‹/å�–消归档入å�£ï¼ˆæœ¬æœŸå�£å¾„,README Known Limitation 记账);数æ�®ä¸Žå¸ä½�完好,å�Žç»åŠ æ�¢å¤�é�¢å�ªæ˜¯ UI + 一个逆å�‘ RPC。`workspace.list` å“�应形状å�˜åŒ–是 pre-release ç›´æ”¹ï¼ˆæ— å…¼å®¹å±‚ï¼‰ã€‚e2e(workspace-management)钉ä½�了「归档→行消失→reload å�Žä»�éš�è—�ã€�日志ä»�在ã€�的全链路;domain 层测试钉ä½�幂ç‰ã€�未知 id æ‹’ç»�ã€�è·¨é‡�å�¯æ�¢å¤�与旧介质默认å�‡çº§ã€‚
diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml
new file mode 100644
index 0000000000..7bcfa9f599
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.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-07-31-telemetry-anonymous-user-id.md
+2026-07-31-telemetry-anonymous-user-id.md: 3c8e3324cb418eac48cb5ae780c55bbcbaf3caa1
+2026-07-31-telemetry-anonymous-user-id.zh.md: 9ff6cf35a90d4087b4ab75987dc9244210e3d46a
diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md
new file mode 100644
index 0000000000..3c8e3324cb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md
@@ -0,0 +1,44 @@
+# Agent Note: Telemetry anonymous user id ($DSH_HOME/.userid) and the OTel Resource user.id
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-telemetry-anonymous-user-id.zh.md)
+
+## Problem
+
+Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-telemetry-default-mount.md)), but the OTel Resource carried only `service.name`/`service.version` — no user-level identity at all, so the collector could neither aggregate per user nor count active users. The only prior ruling on point was an unimplemented one to derive a user id by hashing the hostname/local IP; the dsh-sdk toolchain keeps its own anonymous id (`$DSH_HOME/telemetry.json`), but that is the launcher feed's private fact, unrelated to the OTel feed. The OTel feed needed an anonymous user identity with clean semantics.
+
+## Decision
+
+The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel feed's user identity: `getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. This identity belongs to the OTel feed alone; the dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`), and the two are not shared (the first cut unified both feeds through a shared util package; the user reconsidered and pulled it back — no shared package before a second real consumer exists, revisit when a feed-correlation need appears).
+
+| Ruling | Value | Rationale |
+|---|---|---|
+| Id source | Random UUID v4, never derived from the hostname, network address, or git remote | A derived id is reversible, making "anonymous" a fiction |
+| Storage form | `.userid`, a bare UUID line plus newline, no JSON wrapper | Identity is a standalone fact, not something filed under one telemetry feed's file name/format |
+| IO form | Synchronous IO + a process-lifetime memo keyed by resolved file path | `TelemetryOtel`'s constructor is synchronous (async would reshape plugin loading); one disk touch per process, and mid-run file deletion never affects the running process |
+| Concurrent first launch | Settled by an exclusive-create (`wx`) write; the loser rereads the winner's id | Covers common concurrency (a reread landing in the winner's microsecond create-to-write window can still yield one id per process for that run, converging on the persisted value next launch — a telemetry-grade consequence, accepted) |
+| Loss semantics | File deleted → next launch mints a fresh id; loss is accepted | An anonymous identity has no recovery value; recoverability demands derivation material, which conflicts with anonymity |
+| Write failure | Best-effort: return the in-memory id | Telemetry is never blocked by a read-only home |
+| Report position | Resource attribute, not per-record attributes | Once per batch suffices for Resource-dimension aggregation; per-record injection would touch the seam contract and grow the wire |
+| semconv dependency | `@opentelemetry/semantic-conventions` is not imported | One string constant does not justify a dependency |
+| Home | A module inside `session-telemetry-otel`, not a shared util package | Repo rule: split a package only for a second real consumer; the sdk launcher feed keeps its own store, and no real correlation need exists |
+| Separate switch | None | Identity follows the telemetry master switch (`DSH_TELEMETRY_DISABLED`); telemetry off means nothing reports |
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| Hostname/IP-hash-derived id (the prior ruling) | Reversible means not anonymous; the random UUID is semantically clean — the user ruled to supersede |
+| user.id on every record's attributes (Claude Code's shape) | Touches the session-telemetry seam contract or injects per record, growing the wire; once per batch on the Resource already aggregates |
+| A shared util package unifying both feeds (the first cut) | The only real consumer is the OTel backend; switching the sdk launcher onto it was unification for its own sake — the user reconsidered and pulled it back, to be re-extracted when a correlation need appears |
+| Reusing telemetry.json instead of a new file | The file name/JSON format files the identity under the launcher feed's naming; the OTel feed's identity is a standalone fact |
+| AppCLIEntry reading the id and injecting via config patch | Every surface entry needs wiring; a runtime fact inside deployment config conflates the two |
+| Housing it in `@deepseek-ai/dsh-paths` | paths is pure path computation with zero IO; a persisting identity capability would pollute the package boundary |
+
+## Consequences
+
+- One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism.
+- The OTel feed and the launcher feed each hold their own id (`.userid` vs `telemetry.json`) and cannot be correlated — the direct cost of not extracting a shared package, to be unified when a real correlation need appears.
+- Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable.
+- The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open.
diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md
new file mode 100644
index 0000000000..9ff6cf35a9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md
@@ -0,0 +1,44 @@
+# Agent Note: telemetry 匿�用户 id($DSH_HOME/.userid)与 OTel Resource user.id
+
+Status: implemented
+
+[English](2026-07-31-telemetry-anonymous-user-id.md) | 䏿–‡
+
+## Problem
+
+session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry-default-mount.md)),但 OTel Resource å�ªæœ‰ `service.name`/`service.version`ï¼Œæ²¡æœ‰ä»»ä½•ç”¨æˆ·çº§æ ‡è¯†â€”â€”æŽ¥æ”¶ç«¯æ— æ³•æŒ‰ç”¨æˆ·è�šå�ˆã€�æ— æ³•æ•°æ´»è·ƒç”¨æˆ·ã€‚æ¤å‰�唯一相关å�£å¾„是一æ�¡æœªå®žçŽ°çš„ã€Œhostname/本机 IP 哈希派生 user.idã€�è£�定;dsh-sdk 工具链å�¦æœ‰è‡ªç”¨çš„匿å�� id(`$DSH_HOME/telemetry.json`),但那是 launcher 回æµ�çš„ç§�有事实,与 OTel 回æµ�æ— å…³ã€‚éœ€è¦�ç»™ OTel 回æµ�一个è¯ä¹‰å¹²å‡€çš„匿å��用户身份。
+
+## Decision
+
+`session-telemetry-otel` 包内模å�— `src/user-id.ts` 是 OTel 回æµ�用户身份的属主:`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` è§£æž�,`$DSH_HOME` > `~/.dsh`)ä¸çš„裸 UUID 行,首用生æˆ�éš�机 UUID v4 å¹¶è�½ç›˜ï¼›backend æž„é€ æ—¶æŠŠå®ƒä½œä¸º Resource çš„ `user.id`(OTel semconv æ ‡å‡†ç”¨æˆ·å±žæ€§ï¼‰éš�æ¯�批导出æ�ºå¸¦ä¸€æ¬¡ã€‚该身份å�ªå±žäºŽ OTel 回æµ�ï¼›dsh-sdk launcher telemetry ä¿�留自己的匿å�� id å˜å‚¨ï¼ˆ`telemetry.json`),两者ä¸�共享(åˆ�版曾å�šå…¬ç”¨ util 包统一两æ�¡å›žæµ�,用户å¤�è®®å�Žæ”¶å›žï¼šåœ¨æœ‰ç¬¬äºŒä¸ªçœŸå®žæ¶ˆè´¹è€…之å‰�ä¸�抽公共包,回æµ�å…³è�”需求出现时å†�议)。
+
+| �定 | �值 | �由 |
+|---|---|---|
+| id �� | �机 UUID v4,��从 hostname/网络地�/git remote 派生 | 派生 id ��查,「匿����副实 |
+| å˜å‚¨å½¢æ€� | `.userid` 裸 UUID 行 + æ�¢è¡Œï¼Œæ— JSON 包装 | 身份是独立事实,ä¸�挂在æŸ�æ�¡ telemetry 链路的文件命å��/æ ¼å¼�下 |
+| 读写形æ€� | å�Œæ¥ IO + 进程内按解æž�å�Žæ–‡ä»¶è·¯å¾„ memo | `TelemetryOtel` æž„é€ å‡½æ•°æ˜¯å�Œæ¥çš„(async 迫使æ�’件装载改形);一进程一次盘 IO,è¿�行ä¸åˆ 文件ä¸�å½±å“�本进程 |
+| å¹¶å�‘首å�¯ | `wx` 独å� 写è£�决,è�½è´¥æ–¹é‡�读胜者 id | 覆盖常è§�å¹¶å�‘(é‡�读撞进胜者建档-写入微秒窗ä»�å�¯èƒ½å�„æŒ�一 id 一次è¿�行,下次å�¯åŠ¨æ”¶æ•›åˆ°è�½ç›˜å€¼â€”—telemetry 级å�Žæžœï¼ŒæŽ¥å�—) |
+| 丢失è¯ä¹‰ | æ–‡ä»¶è¢«åˆ â†’ 下次å�¯åЍæ�¢æ–° id,接å�—丢失 | 匿å��èº«ä»½æ— æ�¢å¤�价值;å�¯æ�¢å¤�性è¦�求派生æ��料,与匿å��冲çª� |
+| 写失败 | best-effort è¿”å›žå†…å˜ id | telemetry æ°¸ä¸�å› home å�ªè¯»è¢«é˜»å¡ž |
+| 上报�置 | Resource 属性,��� attributes | �批一次�够接收端按 Resource 维度��;��注入�动 seam 契约且涨 wire 体积 |
+| semconv ä¾�èµ– | ä¸�引 `@opentelemetry/semantic-conventions` 包 | 一个å—符串常é‡�ä¸�值一个ä¾�èµ– |
+| è�½ç‚¹ | `session-telemetry-otel` 包内模å�—,é�žå…¬å…± util 包 | 仓规「有第二个真实消费者æ‰�拆包ã€�ï¼›sdk launcher 回æµ�ä¿�留自有å˜å‚¨ï¼Œæ— 现实关è�”需求 |
+| å�•独开关 | æ— | 身份跟éš� telemetry 整体开关(`DSH_TELEMETRY_DISABLED`);关 telemetry å�³æ•´ä½“ä¸�报 |
+
+## Alternatives considered
+
+| 被拒 | 一���由 |
+|---|---|
+| hostname/IP 哈希派生 id(æ¤å‰�å�£å¾„) | å�¯å��查å�³é�žåŒ¿å��ï¼›éš�机 UUID è¯ä¹‰å¹²å‡€ï¼Œç”¨æˆ·è£�决å�–代 |
+| user.id 放�� record 的 attributes(Claude Code 形�) | �动 session-telemetry seam 契约或��注入,wire 体积涨;Resource �批一次已满足�� |
+| 公用 util 包统一两�回�(�版实现) | 唯一现实消费者是 OTel backend;sdk launcher �用它�是为统一而统一——用户�议收回,回�关�需求出现时�抽包 |
+| å¤�用 telemetry.json ä¸�新建文件 | 文件å��/JSON æ ¼å¼�把身份挂在 launcher 链路命å��下;OTel 回æµ�身份是独立事实 |
+| AppCLIEntry 读好 id ç»� config patch 注入 | æ¯�个 surface å…¥å�£éƒ½è¦�接线;config é‡Œä¼ è¿�行时事实与部署é…�置混淆 |
+| 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO;带�久化的身份能力会污染包边界 |
+
+## Consequences
+
+- 一个 `$DSH_HOME` 在 OTel 回æµ�䏿˜¯ä¸€ä¸ªç¨³å®šç”¨æˆ·ï¼›ä¸�å�Œ home åœ¨æž„é€ ä¸Šå°±æ˜¯ä¸�å�Œç”¨æˆ·ï¼Œæ— è·¨ home å…³è�”机制。
+- OTel 回æµ�与 launcher 回æµ�å�„有å�„çš„ id(`.userid` 与 `telemetry.json`ï¼‰ï¼Œæ— æ³•äº’ç›¸å…³è�”——这是「ä¸�抽公共包ã€�的直接代价,ç‰çœŸå®žå…³è�”需求出现å†�统一。
+- åˆ é™¤ `.userid` å�³é‡�置身份(下次å�¯åŠ¨ç”Ÿæ•ˆï¼‰ï¼›home ä¸�å�¯å†™æ—¶æ¯�进程å�„自æŒ�æœ‰ä¸€ä¸ªå†…å˜ id 直至æ�¢å¤�å�¯å†™ã€‚
+- [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up ä¸ã€ŒåŒ¿å��用户 idã€�项由本决定关é—ï¼›hostname/surface 维度与脱æ•�规则ã€�usage-metrics track ä»�是待办。
diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml
new file mode 100644
index 0000000000..0eddfa1740
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.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-07-31-third-party-memory-mcp-examples.md
+2026-07-31-third-party-memory-mcp-examples.md: e82d65a3a5a60cafcc47df5a6873cf5768cd8b8f
+2026-07-31-third-party-memory-mcp-examples.zh.md: ee0e9f2e1378f9787e09e32cc05dd13a3648254c
diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md
new file mode 100644
index 0000000000..e82d65a3a5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md
@@ -0,0 +1,78 @@
+# Agent Note: Third-party memory MCP examples
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-third-party-memory-mcp-examples.zh.md)
+
+## Problem
+
+A direct vendor integration made one provider's API, configuration, health behavior, and tool semantics part of DSH. That was too much product surface for a capability already expressible through MCP, and it would require repeating the same adaptation for every memory system. Users instead need a small, inspectable way to opt into one external memory server while preserving the generic MCP boundary.
+
+The acceptance bar is stronger than "the socket connects": each reference must support a real DSH write in session A, recall from the provider in a fresh DSH session B, and use of the recalled value. At the same time, provider downloads, accounts, models, embeddings, storage initialization, and separate HTTP processes must remain upstream responsibilities.
+
+## Decision
+
+Ship three default-off Cordis overlay examples under `examples/mcp-memory`: Memorix, MCP Reference Memory, and Engram. Every file inserts exactly one `@deepseek-ai/dsh-mcp-client` row. None is referenced by the shipped composition, and the CLI declares the generic bridge only so an explicitly selected overlay can resolve it.
+
+These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. There is no memory preset registry, vendor-specific DSH plugin, universal memory service, installation UI, migration layer, health checker, or reconnect controller. Another memory MCP server uses the same documented stdio or Streamable HTTP row.
+
+## Responsibility boundary
+
+| Concern | DSH | Upstream provider or user |
+|---|---|---|
+| Parse selected overlay | Yes | Select one file |
+| Start stdio command and stop it on plugin disposal | Yes | Install the pinned executable |
+| Connect to Streamable HTTP and discover tools | Yes | Run and supervise the HTTP service |
+| Register tools as `mcp____` | Yes | Define tool schemas and behavior |
+| Account, auth, model, embedding, storage initialization | No | Yes |
+| Vendor data migration, retry, crash recovery | No | Yes |
+
+The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables while inheriting other ambient variables. Baseline examples add only required overrides; optional provider secrets must be added to `config.env` or configured in the provider's own files.
+
+## Pins, storage, and identity
+
+| Provider | Tested contract |
+|---|---|
+| Memorix | npm `1.3.0`, tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` |
+| MCP Reference Memory | npm `2026.7.4`, package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` |
+| Engram | tag `v1.20.0`, commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` |
+
+Storage remains provider-owned. Memorix uses `~/.memorix/data` and Engram uses `~/.engram` by default. The Reference Memory example sets a stable `$HOME/.dsh-mcp-reference-memory.jsonl` path instead of writing into the installed npm package directory. Each provider's own environment variable can override these locations before DSH starts.
+
+Project identity remains provider-owned: Memorix and Engram use the DSH working directory's Git project, with Engram optionally accepting `ENGRAM_PROJECT`.
+
+## Model guidance
+
+The examples do not patch `@deepseek-ai/dsh-system-prompt`: a config patch replaces a row's complete config and could erase an existing persona. The README instead offers one optional additive instruction:
+
+> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results.
+
+Provider tool descriptions remain authoritative.
+
+## Validation contract
+
+Remote CI never contacts third-party services or consumes secrets. The keyless suite parses all three overlay files, checks their generic bridge and secret boundary, replaces the upstream endpoint with the package-owned MCP fixture server, boots the real Cordis Loader, and proves tool discovery.
+
+Before merge, manual evidence for every pinned provider must separately show:
+
+1. DSH session A calls a write tool and receives success for a unique value.
+2. Fresh DSH session B, under the same provider storage scope, calls search or recall and returns that value without session A's transcript.
+3. Session B uses the recalled value in a subsequent answer.
+
+"Fresh session" means a new DSH session in the same Host. No Host restart is required. The generic MCP client discovers asynchronously and has no automatic reconnect after a child or HTTP transport closes; validation waits for tools before the first turn and uses HMR or a Host restart only after a crash.
+
+## Alternatives considered
+
+**One DSH plugin per provider.** Rejected because it repeats auth, configuration, lifecycle, and tool wrappers that MCP already standardizes and expands ownership for every added provider.
+
+**A memory-provider preset registry.** Rejected because a registry would make third-party versions and recommendations look like a supported DSH product surface. Copyable overlays keep ownership and drift visible.
+
+**Run `npx` or `go run` inside the MCP row.** Rejected after probes showed first-run npm downloads can exceed the MCP initialization timeout and an interrupted `npx` cache can become unusable. DSH starts a server process; it is not the provider package manager. Pinned installation commands are explicit prerequisites.
+
+**Inject the common instruction from the generic MCP client.** Rejected because the bridge serves non-memory MCP servers too, and generic prompt mutation would reintroduce provider semantics into shared runtime code.
+
+## Consequences
+
+Selecting a file gives the model the provider's complete discovered MCP tool surface, with schema/token cost determined by that provider. Removing `--config` removes the memory server. Users accept each upstream license, data policy, cloud cost, and operational model directly.
+
+The earlier vendor-specific change is superseded by this generic path. Future provider drift is handled by updating and revalidating a small example pin rather than adding runtime branches to DSH.
diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md
new file mode 100644
index 0000000000..ee0e9f2e13
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md
@@ -0,0 +1,78 @@
+# Agent Note: 第三方记忆 MCP 示例
+
+Status: implemented
+
+[English](2026-07-31-third-party-memory-mcp-examples.md) | 䏿–‡
+
+## 问题
+
+直接集æˆ�æŸ�个æ��供方会使该æ��供方的 APIã€�é…�ç½®ã€�å�¥åº·çжæ€�行为和工具è¯ä¹‰æˆ�为 DSH 的一部分。对于已ç»�å�¯ä»¥é€šè¿‡ MCP 表达的功能,这会让产å“�接å�£è¿‡äºŽåºžå¤§ï¼Œè€Œä¸”æ¯�接入一个记忆系统都需è¦�é‡�å¤�å�Œæ ·çš„适é…�工作。用户需è¦�的是一ç§�精简ã€�å�¯æ£€æŸ¥çš„æ–¹å¼�,在ä¿�留通用 MCP 边界的å�Œæ—¶ï¼Œé€‰æ‹©å�¯ç”¨ä¸€ä¸ªå¤–部记忆æœ�务器。
+
+éªŒæ”¶æ ‡å‡†ä¸�æ¢äºŽã€Œå¥—接å—å�¯ä»¥è¿žæŽ¥ã€�:æ¯�份å�‚考é…�置都必须支æŒ� DSH 在会è¯� A ä¸å®žé™…写入,在新的 DSH 会è¯� B ä¸ä»Žæ��供方å�¬å›žï¼Œå¹¶ä½¿ç”¨å�¬å›žçš„值。与æ¤å�Œæ—¶ï¼Œæ��供方下载ã€�账户ã€�模型ã€�embeddingã€�å˜å‚¨åˆ�始化和独立 HTTP 进程ä»�由上游负责。
+
+## 决ç–
+
+在 `examples/mcp-memory` 下交付三份默认关é—çš„ Cordis overlay 示例:Memorixã€�MCP Reference Memory å’Œ Engram。æ¯�个文件å�ªæ�’入一个 `@deepseek-ai/dsh-mcp-client` é…�置项。交付组å�ˆä¸�会引用这些文件;CLI(命令行界é�¢ï¼‰ä»…声明通用桥接器,使用户显å¼�选择 overlay æ—¶å�¯ä»¥è§£æž�它。
+
+这些第三方é…�置仅作为互æ“�作å�‚考;收录ä¸�代表 DeepSeek 的认å�¯ã€�推è��ã€�å�ˆä½œå…³ç³»æˆ–æŒ�ç»æ”¯æŒ�承诺。系统没有记忆预设注册表ã€�æ��供方专属 DSH æ�’ä»¶ã€�通用记忆æœ�务ã€�安装 UIã€�è¿�移层ã€�å�¥åº·æ£€æŸ¥å™¨æˆ–é‡�连控制器。其他记忆 MCP æœ�务器å�¯ä»¥ä½¿ç”¨å�Œä¸€ä»½æ–‡æ¡£ä¸çš„ stdio 或 Streamable HTTP é…�置项。
+
+## �责边界
+
+| 事项 | DSH | 上游�供方或用户 |
+|---|---|---|
+| è§£æž�选ä¸çš„ overlay | 是 | 选择一个文件 |
+| å�¯åЍ stdio 命令,并在æ�’ä»¶ dispose(资æº�释放)时将其å�œæ¢ | 是 | 安装固定版本的å�¯æ‰§è¡Œæ–‡ä»¶ |
+| 连接 Streamable HTTP 并�现工具 | 是 | �行并监管 HTTP �务 |
+| 以 `mcp____` 注册工具 | 是 | 定义工具 schema 和行为 |
+| 账户ã€�认è¯�ã€�模型ã€�embeddingã€�å˜å‚¨åˆ�始化 | å�¦ | 是 |
+| �供方数��移��试�崩溃�� | � | 是 |
+
+通用 stdio ä¼ è¾“ä¼šæ¸…é™¤çŽ¯å¢ƒä¸å��ç§°ç±»ä¼¼å‡æ�®çš„å�˜é‡�å’Œ `DSH_*` å�˜é‡�,å�Œæ—¶ç»§æ‰¿å…¶ä»–环境å�˜é‡�ã€‚åŸºçº¿ç¤ºä¾‹ä»…æ·»åŠ å¿…éœ€çš„è¦†ç›–é¡¹ï¼›å�¯é€‰çš„æ��ä¾›æ–¹å¯†é’¥å¿…é¡»æ·»åŠ åˆ° `config.env`,或é…�置在æ��供方自己的文件ä¸ã€‚
+
+## 版本固定ã€�å˜å‚¨ä¸Žèº«ä»½
+
+| �供方 | 已测试契约 |
+|---|---|
+| Memorix | npm `1.3.0`,tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` |
+| MCP Reference Memory | npm `2026.7.4`,package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` |
+| Engram | tag `v1.20.0`,commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` |
+
+å˜å‚¨ä»�ç”±æ��供方负责。Memorix 默认使用 `~/.memorix/data`,Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而ä¸�是写入已安装的 npm 包(package)目录。æ¯�个æ��供方自己的环境å�˜é‡�都å�¯ä»¥åœ¨ DSH å�¯åЍå‰�覆盖这些ä½�置。
+
+项目身份ä»�ç”±æ��供方负责:Memorix å’Œ Engram 使用 DSH 工作目录ä¸çš„ Git é¡¹ç›®ï¼Œå…¶ä¸ Engram 还å�¯ä»¥é€‰æ‹©æŽ¥å�— `ENGRAM_PROJECT`。
+
+## 模型指导
+
+示例ä¸�会修改 `@deepseek-ai/dsh-system-prompt`:é…�ç½® patch 会替æ�¢æŸ�个é…�置项的完整é…�置,å�¯èƒ½æŠ¹é™¤å·²æœ‰ persona。README 改为æ��供一æ�¡å�¯é€‰çš„é™„åŠ æŒ‡ä»¤ï¼š
+
+> 用户�求记�时调用写入工具;涉�历�信�时,主动检索并使用相关记忆。
+
+�供方的工具�述�然是��定义。
+
+## 验�契约
+
+远程 CI ä¸�会访问第三方æœ�åŠ¡æˆ–æ¶ˆè€—å¯†é’¥ã€‚æ— å¯†é’¥å¥—ä»¶è§£æž�全部三份 overlay 文件,检查其通用桥接器和密钥边界,将上游端点替æ�¢ä¸ºåŒ…自带的 MCP fixture(测试å‰�置数æ�®ï¼‰æœ�务器,通过真实 Cordis Loader å�¯åŠ¨ï¼Œå¹¶éªŒè¯�工具å�‘现。
+
+�并�,�个固定版本的�供方都必须分别�供以下人工��:
+
+1. DSH 会� A 调用写入工具,为一个唯一值写入记忆,并收到�功结果。
+2. æ–°çš„ DSH 会è¯� B 在相å�Œçš„æ��供方å˜å‚¨èŒƒå›´ä¸‹è°ƒç”¨æ�œç´¢æˆ–å�¬å›žï¼Œä¸�借助会è¯� A çš„ transcript(文本记录)便å�¯è¿”回该值。
+3. 会è¯� B 在å�Žç»å›žç”ä¸ä½¿ç”¨è¯¥å�¬å›žå€¼ã€‚
+
+「新会è¯�ã€�是指å�Œä¸€ä¸ª Host 䏿–°å»ºçš„ DSH 会è¯�,ä¸�需è¦�é‡�å�¯ Host。通用 MCP å®¢æˆ·ç«¯ä»¥å¼‚æ¥æ–¹å¼�å�‘现工具,å�进程或 HTTP ä¼ è¾“å…³é—å�Žä¸�会自动é‡�连;验è¯�会在第一轮之å‰�ç‰å¾…工具出现,并且å�ªåœ¨å´©æºƒå�Žä½¿ç”¨ HMR 或é‡�å�¯ Host。
+
+## 考虑过的替代方案
+
+**æ¯�个æ��供方使用一个 DSH æ�’件。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºè¿™ä¼šé‡�å¤� MCP å·²ç»�æ ‡å‡†åŒ–çš„è®¤è¯�ã€�é…�ç½®ã€�生命周期和工具包装层,并éš�ç�€æ¯�å¢žåŠ ä¸€ä¸ªæ��供方而扩大维护范围。
+
+**记忆æ��供方预设注册表。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºæ³¨å†Œè¡¨ä¼šè®©ç¬¬ä¸‰æ–¹ç‰ˆæœ¬å’ŒæŽ¨è��看起æ�¥åƒ�å�—支æŒ�çš„ DSH 产å“�接å�£ã€‚å�¯å¤�制的 overlay 让所有æ�ƒå’Œç‰ˆæœ¬å��ç§»ä¿�æŒ�å�¯è§�。
+
+**在 MCP é…�置项内è¿�行 `npx` 或 `go run`。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºæŽ¢æµ‹è¡¨æ˜Žé¦–æ¬¡ npm 下载å�¯èƒ½è¶…过 MCP åˆ�å§‹åŒ–è¶…æ—¶ï¼Œè€Œä¸æ–çš„ `npx` 缓å˜å�¯èƒ½å�˜å¾—ä¸�å�¯ç”¨ã€‚DSH è´Ÿè´£å�¯åЍæœ�务器进程,ä¸�是æ��供方的包管ç�†å™¨ã€‚固定版本的安装命令属于显å¼�å‰�ç½®æ�¡ä»¶ã€‚
+
+**由通用 MCP 客户端注入共用指令。** ä¸�äºˆé‡‡çº³ï¼Œå› ä¸ºè¯¥æ¡¥æŽ¥å™¨ä¹Ÿæœ�务于é�žè®°å¿†ç±» MCP æœ�务器,而且通用æ��示è¯�å�˜æ›´ä¼šæŠŠæ��供方è¯ä¹‰é‡�新带入共享è¿�行时代ç �。
+
+## �果
+
+选择一个文件å�Žï¼Œæ¨¡åž‹å�¯ä»¥ä½¿ç”¨æ��供方å�‘现到的完整 MCP 工具接å�£ï¼›å·¥å…· schema å’Œ token æˆ�本由æ��供方决定。移除 `--config` 就会移除记忆æœ�务器。用户直接接å�—å�„上游的许å�¯è¯�ã€�æ•°æ�®æ”¿ç–ã€�云æœ�务费用和è¿�维模å¼�。
+
+通用方案å�–代了早期针对特定æ��供方的改动。未æ�¥å‡ºçްæ��供方版本å��移时,å�ªéœ€æ›´æ–°å¹¶é‡�新验è¯�一份å°�型示例的固定版本,ä¸�å¿…å�‘ DSH æ·»åŠ è¿�行时分支。
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml
new file mode 100644
index 0000000000..483e4cc132
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.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-07-31-web-cards-toolrow.md
+2026-07-31-web-cards-toolrow.md: caa18563a9e66f882873e8d7e84cc3ac20702033
+2026-07-31-web-cards-toolrow.zh.md: 4e9c429497e1265b4b39ed2479f382a2f36e7741
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md
new file mode 100644
index 0000000000..caa18563a9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md
@@ -0,0 +1,32 @@
+# Agent Note: Card tool rows collapse through one ToolRow
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-web-cards-toolrow.zh.md)
+
+## Problem
+
+The Web client grew five card render intents over successive PRs — terminal, diff, read, search, web — each landing as a keyed toolview registrant under `packages/client/ui-conversation/src/client/toolviews/`. They diverged in two ways the earlier PRs each acknowledged but deferred:
+
+- **Chrome duplication.** `read-row`, `search-row`, `web-row`, and `file-mutation-row` each hand-drew the summary row (leading state slot, visually-hidden status, title, separator dot, path-link/summary) as their own `` with a private `.module.css`, instead of composing the shared `ToolRow`. `read-row` carried a `jscpd:ignore` marker naming the duplication and pointing at "a separate change tracked for all rows at once" — this change.
+- **Resident vs. collapsed.** Those four rows kept their card (`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`) resident below the summary — always expanded — while the terminal card (via `GenericToolCard`/`BashRow`) and every text row started collapsed behind ToolRow's whole-row expand. A conversation with several read/search/web/edit calls became a wall of always-open cards, defeating the summary-surface purpose of the message flow.
+
+## Decision
+
+`ToolRow` owns every card kind, and every keyed card row composes it. ToolRow already took `terminal` and `diff` card material; it now also takes `read`, `search`, and `web`, rendering whichever is present in its collapsed-by-default expanded body through the matching primitive (capped at the chat `CHAT_*` bounds). A call carries at most one card kind, so the props are mutually exclusive and the body picks the first present.
+
+The four keyed rows — `ReadRow`, `SearchRow`, `WebRow`, `FileMutationRow` — drop their hand-drawn chrome and private CSS and become thin `ToolRow` compositions, exactly like `AskQuestionRow`: derive the card model, pass it as the matching ToolRow prop, forward `filePath`/`onOpenFile` for the file tools and `output`/`errorSummary` for the cardless failure paths. Each row is now `ToolRowProps & PropsLocale<'conversation'>` and registers with `locale: NS`, because ToolRow needs the conversation `t` for its terminal/code body copy. `GenericToolCard` (the render-site fallback) does the same for read/search/web, so a card-declaring tool without its own keyed row collapses identically.
+
+The `DetailsPanel` Output section is unchanged: the panel is the single-call reading surface, so it renders each card resident at the primitive's full height, and a capped search keeps its recovery footer there.
+
+## Consequences
+
+- One expand interaction across all tool rows: collapsed one-line summary, whole row toggles the card. The card is not in the DOM until expanded (`DisclosureRow` renders `children` only when open), so tests assert absent-then-present around a `[data-expandable]` click.
+- Deleted: `read-row.module.css`, `search-row.module.css`, `web-row.module.css`, `file-mutation-row.module.css`, `GenericToolCard.module.css`. The rows carry no CSS of their own; ToolRow's module owns the chrome and the card-body indentation.
+- The cardless failure paths (an errored mutation, an errored/nested/legacy search) no longer draw their own `.failure`/recovery `
`; they ride ToolRow's `output` (Output section) and `errorSummary` (collapsed summary first line), which already flatten the result text with the `error.name: error.code` fallback.
+- `bash-sample` keeps its own local expand chrome deliberately (the third-party-posture exemplar that never imports the chat domain); it was already collapsed, so its behavior is unchanged.
+
+## Alternatives considered
+
+- **Keep the rows resident, only unify chrome.** Rejected: the user's requirement is default-collapsed, and resident cards are what made the flow unscannable.
+- **A shared `CardRow` wrapper between the rows and ToolRow.** Rejected: ToolRow already is that wrapper once it takes every card kind; a second layer would be the premature extraction the package rules warn against.
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md
new file mode 100644
index 0000000000..4e9c429497
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md
@@ -0,0 +1,32 @@
+# Agent Note:å�¡ç‰‡å·¥å…·è¡Œé€šè¿‡å�Œä¸€ä¸ª ToolRow 折å�
+
+Status: implemented
+
+[English](2026-07-31-web-cards-toolrow.md) | 䏿–‡
+
+## 问题
+
+Web 客户端在连ç»å‡ 个 PR 里长出五ç§�å�¡ç‰‡æ¸²æŸ“æ„�图——terminalã€�diffã€�readã€�searchã€�web,æ¯�一ç§�都作为一个键控 toolview 注册项è�½åœ¨ `packages/client/ui-conversation/src/client/toolviews/` 下。它们在两处出现分æ§ï¼Œä¹‹å‰�æ¯�个 PR 都承认å�´æŽ¨è¿Ÿå¤„ç�†ï¼š
+
+- **Chrome é‡�å¤�。** `read-row`ã€�`search-row`ã€�`web-row`ã€�`file-mutation-row` å�„自把摘è¦�行(行首状æ€�æ§½ã€�视觉éš�è—�状æ€�ã€�æ ‡é¢˜ã€�分隔点ã€�路径链接/摘è¦�)手绘æˆ�自己的 `
`,é…�一份ç§�有 `.module.css`,而ä¸�是组å�ˆå…±äº«çš„ `ToolRow`。`read-row` 带ç�€ä¸€ä¸ª `jscpd:ignore` æ ‡è®°ï¼Œç‚¹å��这处é‡�å¤�并指å�‘"一处针对所有行一次性处ç�†çš„独立改动"——就是本次改动。
+- **常驻 vs 折å� 。** 那四个行把å�¡ç‰‡ï¼ˆ`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`)常驻在摘è¦�下方——始终展开——而终端å�¡ç‰‡ï¼ˆç»� `GenericToolCard`/`BashRow`)与æ¯�个文本行都从折å� 状æ€�èµ·æ¥ï¼Œè—�在 ToolRow 的整行展开之å�Žã€‚一个有多个 read/search/web/edit 调用的对è¯�å°±æˆ�äº†ä¸€å µå§‹ç»ˆæ‰“å¼€çš„å�¡ç‰‡å¢™ï¼Œè¿�背了消æ�¯æµ�作为摘è¦�é�¢çš„目的。
+
+## 决ç–
+
+`ToolRow` 拥有æ¯�一ç§�å�¡ç‰‡ï¼Œè€Œæ¯�个键控å�¡ç‰‡è¡Œéƒ½ç»„å�ˆå®ƒã€‚ToolRow 原本就接收 `terminal` 与 `diff` å�¡ç‰‡æ��料;现在还接收 `read`ã€�`search`ã€�`web`,在其默认折å� 的展开 body é‡Œç”¨å¯¹åº”åŽŸè¯æ¸²æŸ“当å‰�å˜åœ¨çš„那一ç§�(按 chat çš„ `CHAT_*` 上é™�截æ–)。一次调用最多æ�ºå¸¦ä¸€ç§�å�¡ç‰‡ï¼Œå› æ¤è¿™äº› prop 互斥,body å�–第一个å˜åœ¨çš„。
+
+四个键控行——`ReadRow`ã€�`SearchRow`ã€�`WebRow`ã€�`FileMutationRow`——丢掉手绘 chrome 与ç§�有 CSS,æˆ�为薄薄的 `ToolRow` 组å�ˆï¼Œä¸Ž `AskQuestionRow` å®Œå…¨ä¸€æ ·ï¼šæŽ¨å¯¼å�¡ç‰‡æ¨¡åž‹ï¼Œä½œä¸ºå¯¹åº”çš„ ToolRow prop ä¼ å…¥ï¼Œä¸ºæ–‡ä»¶å·¥å…·è½¬å�‘ `filePath`/`onOpenFile`ï¼Œä¸ºæ— å�¡ç‰‡çš„失败路径转å�‘ `output`/`errorSummary`。æ¯�个行现在是 `ToolRowProps & PropsLocale<'conversation'>` 并以 `locale: NS` æ³¨å†Œï¼Œå› ä¸º ToolRow 需è¦�对è¯�çš„ `t` æ�¥æ¸²æŸ“其终端/代ç � body 文案。`GenericToolCard`(渲染点兜底)对 read/search/web å�šå�Œæ ·çš„事,所以一个没有自己键控行的å�¡ç‰‡å£°æ˜Žå·¥å…·ä¹Ÿä»¥å�Œæ ·æ–¹å¼�折å� 。
+
+`DetailsPanel` çš„ Output 区ä¸�å�˜ï¼šé�¢æ�¿æ˜¯å�•次调用的阅读é�¢ï¼Œå› æ¤å®ƒä»¥åŽŸè¯çš„完整高度常驻渲染æ¯�å¼ å�¡ç‰‡ï¼Œè¢«æˆªæ–çš„æ�œç´¢ä¹ŸæŠŠæ�¢å¤�脚注留在那里。
+
+## �果
+
+- 所有工具行共享一套展开交互:折å� 时是å�•行摘è¦�,整行切æ�¢å�¡ç‰‡ã€‚å�¡ç‰‡åœ¨å±•å¼€å‰�ä¸�在 DOM 里(`DisclosureRow` å�ªåœ¨æ‰“开时渲染 `children`ï¼‰ï¼Œå› æ¤æµ‹è¯•围绕一次 `[data-expandable]` 点击æ–言"å…ˆæ— å�Žæœ‰"。
+- å·²åˆ é™¤ï¼š`read-row.module.css`ã€�`search-row.module.css`ã€�`web-row.module.css`ã€�`file-mutation-row.module.css`ã€�`GenericToolCard.module.css`。这些行ä¸�å†�带自己的 CSSï¼›ToolRow çš„ module 拥有 chrome 与å�¡ç‰‡ body 的缩进。
+- æ— å�¡ç‰‡çš„失败路径(出错的改动,出错/嵌套/旧日志的æ�œç´¢ï¼‰ä¸�å†�画自己的 `.failure`/æ�¢å¤� `
`;它们改走 ToolRow çš„ `output`(Output 区)与 `errorSummary`(折å� 摘è¦�首行),å�Žè€…å·²ç»�用 `error.name: error.code` 兜底压平结果文本。
+- `bash-sample` 有æ„�ä¿�留自己本地的展开 chrome(第三方姿æ€�的范例,从ä¸�引入 chat 域);它本æ�¥å°±æ˜¯æŠ˜å� çš„ï¼Œå› æ¤è¡Œä¸ºä¸�å�˜ã€‚
+
+## 考虑过的替代方案
+
+- **ä¿�æŒ�行常驻,å�ªç»Ÿä¸€ chrome。** å�¦å†³ï¼šç”¨æˆ·çš„è¦�求是默认折å� ,而常驻å�¡ç‰‡æ£æ˜¯è®©æµ�ä¸�å�¯æ‰«è¯»çš„åŽŸå› ã€‚
+- **在行与 ToolRow ä¹‹é—´åŠ ä¸€å±‚å…±äº«çš„ `CardRow` 包装。** å�¦å†³ï¼šToolRow 一旦接收æ¯�一ç§�å�¡ç‰‡ï¼Œå®ƒæœ¬èº«å°±æ˜¯é‚£å±‚包装;å†�åŠ ä¸€å±‚å°±æ˜¯ package 规则è¦å‘Šçš„过早抽å�–。
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml
new file mode 100644
index 0000000000..f06c290bb3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.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-07-31-web-default-search.md
+2026-07-31-web-default-search.md: ddc047a963212cb228da67c6c33128877cacf92c
+2026-07-31-web-default-search.zh.md: 05c30b625953ccd54c127a97b646ad7db75f693b
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md
new file mode 100644
index 0000000000..ddc047a963
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md
@@ -0,0 +1,35 @@
+# Agent Note: Default Web search in the Web/headless composition
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-web-default-search.zh.md)
+
+## Problem
+
+The harness had a complete Web capability family—provider registry, DeepSeek/Exa/Perplexity search providers, local fetch, stable model tools, and structured result presentation—but the shipped `dsh web` composition mounted none of it. The model could not discover current information unless a deployment supplied a custom overlay. Merely mounting the existing DeepSeek provider would not complete the WebUI path: the Models page stores `DEEPSEEK_API_KEY` through `ctx.credentials`, while the search provider froze only the process environment at plugin load, so a key entered or rotated in the running UI would not reach search.
+
+## Decision
+
+`apps/cli/config/web.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared overlay makes only `web_search` a default for browser and headless sessions; the TUI composition remains unchanged. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows.
+
+DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered.
+
+Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
+
+The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped deployment already defaults to `danger-full-access`; a future restricted-network product stance must add a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
+
+## Alternatives considered
+
+**Mount only `dsh-tool-web`.** Rejected because stable schemas without registered providers would make every default call fail; enablement and backend availability are deliberately separate, but a shipped default must supply its intended implementations.
+
+**Read `$DSH_HOME/.env` from `cordis.yml` or hoist it into `process.env`.** Rejected because the credential provider owns that document, environment values are read-only overrides, and hoisting would make stored keys unrotatable while bypassing the audited secret boundary.
+
+**Freeze `process.env.DEEPSEEK_API_KEY` at provider load.** Rejected because the Web Models page writes through `ctx.credentials`; the product's documented first-run path must make the next operation work without a restart.
+
+**Mount Web tools in `base.cordis.yml`.** Rejected because that would also change the TUI deployment. The browser and headless entries already share `web.cordis.yml`; they gain the capability together while TUI remains an explicit later decision.
+
+**Enable search and fetch together.** Rejected because default `web_fetch` would allow model-selected anonymous outbound HTTP(S) retrieval to arbitrary URLs. Search covers discovery; deployments that accept broader retrieval can opt into `dsh-web-fetch-local` and set `dsh-tool-web`'s `fetch` option to `true` in their overlay.
+
+## Consequences
+
+Web/headless model requests carry only the `web_search` schema and search-only prompt guidance in native mode; Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The real-composition smoke test pins the absence of `web_fetch`; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility.
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md
new file mode 100644
index 0000000000..05c30b6259
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Webï¼�æ— å¤´ç»„å�ˆä¸çš„默认 Web æ�œç´¢
+
+Status: implemented
+
+[English](2026-07-31-web-default-search.md) | 䏿–‡
+
+## 问题
+
+该 harness 已具备完整的 Web 能力体系:æ��供方注册表ã€�DeepSeekã€�Exa å’Œ Perplexity æ�œç´¢æ��供方ã€�本地抓å�–ã€�稳定的é�¢å�‘模型工具,以å�Šç»“构化结果呈现,但已交付的 `dsh web` 组å�ˆæ²¡æœ‰æŒ‚载其ä¸ä»»ä½•一项。除é�žéƒ¨ç½²æ��供自定义覆盖层,å�¦åˆ™æ¨¡åž‹æ— 法å�‘现最新信æ�¯ã€‚仅挂载现有 DeepSeek æ��供方ä»�æ— æ³•æ‰“é€š WebUI 链路:Models 页é�¢é€šè¿‡ `ctx.credentials` å˜å‚¨ `DEEPSEEK_API_KEY`,而æ�œç´¢æ��供方å�ªä¼šåœ¨æ�’ä»¶åŠ è½½æ—¶å›ºå®šè¯»å�–è¿›ç¨‹çŽ¯å¢ƒï¼Œå› æ¤åœ¨è¿�行ä¸çš„ UI 输入或轮æ�¢çš„å¯†é’¥æ— æ³•ç”¨äºŽæ�œç´¢ã€‚
+
+## 决ç–
+
+`apps/cli/config/web.cordis.yml` 明确挂载 `dsh-web`,é…�ç½® `searchProvider: deepseek-official`,å�Œæ—¶æŒ‚è½½ `dsh-web-search-deepseek`,并以 `fetch: false` 挂载 `dsh-tool-web`。它ä¸�挂载 `dsh-web-fetch-local`,也ä¸�选择抓å�–æ��供方。共享覆盖层å�ªå°† `web_search` 设为æµ�è§ˆå™¨ä¸Žæ— å¤´ä¼šè¯�的默认工具;TUI 组å�ˆä¿�æŒ�ä¸�å�˜ã€‚显å¼�æ�œç´¢æ��供方 id 使选择ä¸�å�—注册顺åº�å½±å“�,å�Œæ—¶ä¸ªäººè¦†ç›–层或 `--config` 覆盖层ä»�å�¯æ›¿æ�¢æˆ–ç¦�用这些é…�置项。
+
+DeepSeek æ�œç´¢ä½¿ç”¨ä¸Žå®˜æ–¹ä¼šè¯�适é…�器相å�Œçš„ `DEEPSEEK_API_KEY` 凿�®å¼•用。æ��供方在æ¯�次æ�œç´¢å†…部通过å�¯é€‰çš„ `ctx.credentials` æœ�务解æž�该引用;å�ªæœ‰æœªæŒ‚载该 seam 的组å�ˆæ‰�会回退到å�¯åŠ¨è¿›ç¨‹çš„çŽ¯å¢ƒå�˜é‡�,é�žç©ºçš„ `apiKey` å—é�¢å€¼ä»�作为程åº�化é…�置的最å�Žå…œåº•ã€‚å› æ¤ï¼Œç”± Web çš„ Models 页å˜å‚¨æˆ–è½®æ�¢çš„å¯†é’¥æ— éœ€é‡�å�¯å�³å�¯ç”¨äºŽä¸‹ä¸€æ¬¡æ�œç´¢ï¼Œæ��ä¾›æ–¹ä¹Ÿæ— éœ€ä¿�留该值。由于 `WebSearchProvider.available()` 是å�Œæ¥æ–¹æ³•,它会将已安装解æž�器视为本地å�¯ç”¨ï¼›è‹¥åЍæ€�凿�®ç¼ºå¤±ï¼Œæ“�作会以æ��供方专属错误ç � `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema ä»�ä¿�æŒ�注册。
+
+æ�œç´¢ç«¯ç‚¹ä¸Ž chat completions ä¿�æŒ�独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基å�€ï¼Œ`DEEPSEEK_BASE_URL` 则继ç»é…�置会è¯�请求。æ¯�次 `web_search` 都会å�‘起一次辅助 DeepSeek Messages 调用,并æ�ºå¸¦åŽŸç”Ÿæ�œç´¢æœ�务器工具。å�‘出请求å‰�一刻,æ��供方会å�‘å�‘起请求的 agent(智能体)会è¯�è¿½åŠ ä»…ç”¨äºŽæ—¥å¿—çš„ LLM(大è¯è¨€æ¨¡åž‹ï¼‰è¯·æ±‚事件 `web/deepseek-search-llm-request`,其ä¸åŒ…å�«å·²è§£æž�端点ã€�API 版本,以å�Šä¸�å�«å¯†é’¥çš„精确 JSON è¯·æ±‚ä½“ã€‚å‡æ�®é¢„检ä»�留在æ��供方内部,并与调用方å�–消å˜åœ¨ç«žæ€�;这两项关注点都ä¸�会扩展通用 Web seam æˆ–å‡æ�® seam。
+
+默认挂载ä¸�会创建 Web 专用æ�ƒé™�ç–略。`web_search` 在 bashï¼�文件系统沙箱å�Šå®¡æ‰¹é¢„设之外执行,并é�µå¾ª `dsh-tool-web` 的现有契约。组å�ˆä¸�挂载 `web_fetch` 或本地抓å�–æ��ä¾›æ–¹ï¼Œå› æ¤é»˜è®¤é…�ç½®ä¸�会å…�许模型自行选择任æ„� URL 进行抓å�–。已交付部署的默认值本就是 `danger-full-access`;未æ�¥å¦‚果产å“�采å�–å�—é™�网络ç–ç•¥ï¼Œå¿…é¡»æ·»åŠ `tools/pre-execute` ç–略或按能力é™�制网络访问,而ä¸�能暗示文件系统访问模å¼�会管辖 Web 调用。
+
+## 考虑过的替代方案
+
+**仅挂载 `dsh-tool-web`。** �予采纳:稳定的 schema 如果没有已注册�供方,�次默认调用都会失败。�用状�与�端�用性刻�分离,但已交付的默认�置必须�供其预期实现。
+
+**从 `cordis.yml` 读å�– `$DSH_HOME/.env`,或将其æ��å�‡åˆ° `process.env`。** ä¸�äºˆé‡‡çº³ï¼šå‡æ�®æ��供方拥有该文件,环境å�˜é‡�值是å�ªè¯»è¦†ç›–ï¼›æ��å�‡å�Žå˜å‚¨çš„å¯†é’¥å°†æ— æ³•è½®æ�¢ï¼Œè¿˜ä¼šç»•过ç»�审计的密钥边界。
+
+**在æ��ä¾›æ–¹åŠ è½½æ—¶å›ºå®šè¯»å�– `process.env.DEEPSEEK_API_KEY`。** ä¸�予采纳:Web Models 页é�¢é€šè¿‡ `ctx.credentials` 写入密钥;产å“�文档规定的首次è¿�行路径必须ä¿�è¯�下一次æ“�ä½œæ— éœ€é‡�å�¯å�³å�¯ç”Ÿæ•ˆã€‚
+
+**在 `base.cordis.yml` 䏿Œ‚è½½ Web 工具。** ä¸�予采纳:这也会改å�˜ TUI 部署。æµ�è§ˆå™¨ä¸Žæ— å¤´å…¥å�£å·²ç»�共享 `web.cordis.yml`;两者会一å�ŒèŽ·å¾—è¯¥èƒ½åŠ›ï¼Œæ˜¯å�¦ä¸º TUI å�¯ç”¨åˆ™ä»�留作å�Žç»æ˜¾å¼�决ç–。
+
+**å�Œæ—¶å�¯ç”¨æ�œç´¢å’ŒæŠ“å�–。** ä¸�予采纳:默认å�¯ç”¨ `web_fetch` 会å…�许模型自行选择任æ„� URL,执行匿å��出站 HTTP(S) 抓å�–。æ�œç´¢è´Ÿè´£å�‘现信æ�¯ï¼›æŽ¥å�—更广泛抓å�–范围的部署å�¯ä»¥åœ¨è¦†ç›–层ä¸é€‰æ‹©å�¯ç”¨ `dsh-web-fetch-local`,并将 `dsh-tool-web` çš„ `fetch` 选项设为 `true`。
+
+## �果
+
+Webï¼�æ— å¤´æ¨¡åž‹è¯·æ±‚åœ¨åŽŸç”Ÿæ¨¡å¼�下å�ªä¼šæ�ºå¸¦ `web_search` schema,以å�Šä»…用于æ�œç´¢çš„æ��示è¯�指引;Code Mode 通过 `run_code` 公开相å�Œçš„æ�œç´¢èƒ½åŠ›ã€‚è¯¥æ��示è¯�è¦�求模型使用返回的 snippet,且ç»�ä¸�会å�‘模型æ��å�Šå·²ç¦�用的 `web_fetch` 工具。æ�œç´¢ä¼šå¢žåŠ ä¸€æ¬¡å®Œæ•´çš„è¾…åŠ©æ¨¡åž‹è°ƒç”¨ï¼Œå¹¶å�¯èƒ½å¤šæ¬¡ä½¿ç”¨åŽŸç”Ÿæœ�务器工具;å�‘起会è¯�的日志ä»�å�¯ç²¾ç¡®é‡�建其ä¸�å�«å¯†é’¥çš„请求。默认é…�置会æ��ä¾›æ�œç´¢ç»“æžœ snippet 与æ�¥æº�元数æ�®ï¼Œä½†ä¸�支æŒ�ä»»æ„�页é�¢æŠ“å�–;需è¦�抓å�–完整页é�¢çš„部署必须自行选择å�¯ç”¨æŠ“å�–。Web 快照通é�“会å�¯åŠ¨å·²äº¤ä»˜é…�ç½®æ ‘ï¼Œä½¿ç”¨æœ¬åœ° Messages fixture(测试å‰�置数æ�®ï¼‰ï¼Œç»�由真实 DeepSeek æ��供方驱动一次回放的 `web_search` 调用,æ–言æŒ�久化的辅助请求与结构化结果,并固定最终æµ�览器呈现。真实组å�ˆå†’烟测试固定了ä¸�æ��ä¾› `web_fetch` 这一事实;æ��供方测试固定缺失ã€�å·²å˜å‚¨å�Šå·²è½®æ�¢å‡æ�®çš„行为,以å�Šå—é�¢å€¼ä¸ŽçŽ¯å¢ƒå�˜é‡�的兼容性。
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
index 97793a906a..dd15a55a21 100644
--- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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-web-telemetry-default-mount.md
-2026-07-31-web-telemetry-default-mount.md: 6c1fdaa8719ee01726b51db9a469ff659cbac476
-2026-07-31-web-telemetry-default-mount.zh.md: b447832527ba9731097cd0776060db11ee4dfc30
+2026-07-31-web-telemetry-default-mount.md: e9ec7d0cda37db44e753c9aee572763b7e24ada6
+2026-07-31-web-telemetry-default-mount.zh.md: 68b411d0668772ce81d7f323c2d286714a223ca4
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
index 6c1fdaa871..e9ec7d0cda 100644
--- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
@@ -35,5 +35,5 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl
## Consequences
- A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally.
-- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), and the usage-metrics track are the explicit follow-ups of this decision.
+- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, the remaining identity Resource attributes (hostname / surface; the anonymous user id shipped via the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md)), and the usage-metrics track are the explicit follow-ups of this decision.
- Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name.
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
index b447832527..68b411d066 100644
--- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
@@ -35,5 +35,5 @@ Status: implemented
## Consequences
- æ— æœ¬åœ° collector 的开å�‘者跑 `dsh web` 会对生产 endpoint æ¯� 10s å�‘一次 POST(è�”ä¸�通则é�™é»˜å¤±è´¥ï¼ŒOTel diag logger 未注册);本地开å�‘设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。
-- **当å‰�零脱æ•�规则挂载**:导出å�³åŽŸå§‹æ�•获副本(用户/助手消æ�¯å…¨æ–‡ã€�工具å�‚数与结果ã€�system promptã€�`session.cwd` 本地路径)。跨信任边界å‰�必须挂 `telemetry/record` 规则——脱æ•�规则ã€�身份 Resource 维度(hostname/匿å�� user id/surface)ã€�使用数æ�® metrics è½¨ä¸‰ä»¶æ˜¯æœ¬å†³ç–æ˜Žç¡®çš„å�Žç»å·¥ä½œã€‚
+- **当å‰�零脱æ•�规则挂载**:导出å�³åŽŸå§‹æ�•获副本(用户/助手消æ�¯å…¨æ–‡ã€�工具å�‚数与结果ã€�system promptã€�`session.cwd` 本地路径)。跨信任边界å‰�必须挂 `telemetry/record` 规则——脱æ•�规则ã€�其余身份 Resource 维度(hostname/surface;匿å�� user id 已由[匿å��用户 id Note](2026-07-31-telemetry-anonymous-user-id.md)è�½åœ°ï¼‰ã€�使用数æ�® metrics è½¨æ˜¯æœ¬å†³ç–æ˜Žç¡®çš„å�Žç»å·¥ä½œã€‚
- å¤�ç”¨è¿™æ£µæ ‘çš„æµ‹è¯•è½½å…·ï¼ˆå¦‚ `apps/web/tests/scaffold.ts`)须显å¼�å…³å�œè¯¥è¡Œï¼Œå�¦åˆ™ fixture 会è¯�会æµ�å�‘ env 里碰巧å˜åœ¨çš„ collector。
diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
index 05d448c79b..deafb47f70 100644
--- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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-20-gui-testing-system.md
-2026-07-20-gui-testing-system.md: 8c6dafb18fc207fc4eac780ba18e108267bc28b1
-2026-07-20-gui-testing-system.zh.md: 9a0de4bfa8fa2f8de55beef53bedde51649c5d9c
+2026-07-20-gui-testing-system.md: 4a1600bbef7ef795677a446228fcc279a4b53f39
+2026-07-20-gui-testing-system.zh.md: 2aa5d7f66783c69964cabf7eb18a018b54528a33
diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md
index 8c6dafb18f..4a1600bbef 100644
--- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md
+++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md
@@ -22,7 +22,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up:
| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` |
| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
-Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
+Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
- **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites.
- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details.
diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
index 9a0de4bfa8..2aa5d7f667 100644
--- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
+++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
@@ -22,7 +22,7 @@ GUI æ ˆéœ€è¦�考虑多ç§�应用形æ€�,å�Œåº”用形æ€�内的ä¸�å�Œè¿�行环境
| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状�机与时�:��/去�/翻页/�观清稿/pendingBuffers/�连/退�) | **「事件�列进→快照出�黄金路径**:�编程�体 + deferred 控时� + fake timers 控退� | `packages/client/{runtime,connection}/tests/` |
| 3 组装呈现层 | 构建产物 × 真实 client loader 与æ�’件组å�ˆ | 归应用所有的è¯ä¹‰å¿«ç…§ä¼šåœ¨ jsdom 下å�¯åŠ¨å…¨éƒ¨ 8 个已构建的 client æ�’件,以固定确定性的跨æ�’件状æ€�å�˜åŒ–;独立使用 Playwright 裸库的冒烟测试负责验è¯�真实æµ�览器/承载层边界,真 host ç”¨ä¾‹åœ¨æ— å¯†é’¥æ—¶è‡ªè¡Œè·³è¿‡ï¼›æ— å¯†é’¥æµ�览器 e2e 车é�“会ç¦�用交付é…�ç½®ä¸çš„æ¨¡åž‹é€‚é…�器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装ä¸å›žæ”¾å½•制的会è¯� fixture,与会è¯�区 aria 期望输出比对([web e2e 车é�“](../testing/2026-07-24-web-gui-browser-e2e-lane.md)ã€�[必需 CI é—¨ç¦�](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`ã€�`apps/web/tests/smoke-{fixture,real}.e2e.ts`ã€�`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
-层间纪律:**下层å�„测å�„的,上层ä¸�é‡�测下层**:应用è¯ä¹‰å¿«ç…§å�ªå›ºå®šç»„装å�Žæ�’件边界上的用户å�¯è§�投影,Playwright 冒烟测试负责验è¯�æµ�览器与承载层是å�¦å˜æ´»ï¼›wire è¯ä¹‰å½’ 1 层,数æ�®è¯ä¹‰å½’ 2 层。纯函数层(lineage/partial/notifier/fold-adapter)éš� 2 层å�ŒåŒ… tests/ é›¶å�‡ä½“直测。
+层间纪律:**下层å�„测å�„的,上层ä¸�é‡�测下层**:应用è¯ä¹‰å¿«ç…§å�ªå›ºå®šç»„装å�Žæ�’件边界上的用户å�¯è§�投影,Playwright 冒烟测试负责验è¯�æµ�览器与承载层是å�¦å˜æ´»ï¼›wire è¯ä¹‰å½’ 1 层,数æ�®è¯ä¹‰å½’ 2 层。纯函数层(lineage/partial/notifier/transcript-adapter)éš� 2 层å�ŒåŒ… tests/ é›¶å�‡ä½“直测。
- **host 与 client æº�ç �**å�‡çº³å…¥å…¨ä»“ per-file 100% 覆盖率门ç¦�,仅排除 `vitest.config.ts` ä¸å¸¦æ³¨é‡Šçš„å°‘é‡�æµ�览器级例外;组件套件通过é€�文件 jsdom pragma å’Œ Testing Library è¿�行,ä¸�会改å�˜ Node 套件。
- **归应用所有的è¯ä¹‰å¿«ç…§**读å�–已构建的 client bundle,通过真实 loader 执行它们,并且å�ªé©±åŠ¨ç¡®å®šæ€§çš„ fixture é’©å�。它们负责固定侧边æ �æ ‡ç¾ã€�é�¢åŒ…屑和 `document.title` ç‰ç¨³å®šå�¯è§�状æ€�,而ä¸�固定 CSS åƒ�ç´ æˆ–ä¸‹å±‚çŠ¶æ€�机细节。
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml
new file mode 100644
index 0000000000..70c5ce5a90
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.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-07-22-product-first-root-readme.md
+2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e
+2026-07-22-product-first-root-readme.zh.md: 1c4d5fa53854bfcade9742da1fb74d9636909f84
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
new file mode 100644
index 0000000000..32542a4501
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
@@ -0,0 +1,33 @@
+# Agent Note: Product-first root README
+
+Status: implemented
+
+English | [䏿–‡](2026-07-22-product-first-root-readme.zh.md)
+
+## Problem
+
+The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works.
+
+## Decision
+
+The root README preserves its existing structure, order, and wording wherever the underlying fact remains correct. A refresh changes only stale claims and adds material needed to represent shipped surfaces; it does not use repository growth as a reason to reframe the whole page.
+
+A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The existing development-stage statement identifies DeepSeek Harness as being in internal testing.
+
+The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it.
+
+Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page.
+
+## Alternatives considered
+
+**Rewrite the README around a new product narrative.** A complete rewrite can make every current surface prominent, but it replaces accurate, reviewed copy and creates unnecessary churn. Current facts fit the established product-first structure.
+
+**Present the repository as an SDK and package catalog.** This exposes implementation breadth immediately but makes a new reader reconstruct the product from package names. The package map and generated capability graph remain the authoritative inventories.
+
+**Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides.
+
+**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs.
+
+## Consequences
+
+Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, release-stage claims, or high-level capability families, while exhaustive detail remains linked rather than copied.
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
new file mode 100644
index 0000000000..1c4d5fa538
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 产å“�ä¼˜å…ˆçš„æ ¹ README
+
+Status: implemented
+
+[English](2026-07-22-product-first-root-readme.md) | 䏿–‡
+
+## 问题
+
+æ ¹ README 是仓库的产å“�å…¥å�£ã€‚其产å“�ä¼˜å…ˆçš„ç»“æž„å’Œæ—¢æœ‰è¯æ°”ä»�然有效,但éš�ç�€è¿�行时ä¸�æ–æ‰©å±•,具体入å�£å’Œèƒ½åŠ›å£°æ˜Žä¼šé€�æ¸�陈旧。é‡�写事实ä»�ç„¶æ£ç¡®çš„ç« èŠ‚ï¼Œä¼šæ‰©å¤§è¯„å®¡èŒƒå›´ï¼Œä¹Ÿä¼šä¸¢å¼ƒå·²ç»�行之有效的措辞。
+
+## 决ç–
+
+å�ªè¦�背å�Žçš„事实ä»�ç„¶æ£ç¡®ï¼Œæ ¹ README å°±ä¿�留既有结构ã€�顺åº�和措辞。刷新时å�ªä¿®æ£é™ˆæ—§å£°æ˜Žï¼Œå¹¶è¡¥å……呈现已交付内容所需的信æ�¯ï¼›ä¸�ä¼šå› ä¸ºä»“åº“è§„æ¨¡å¢žé•¿å°±é‡�构整篇å�™äº‹ã€‚
+
+安装说明之å‰�çš„ä¸€åˆ™æ–‡å—æ„Ÿè°¢å†…测用户,说明功能和体验ä»�待完善,并邀请大家通过ä¼�业微信群直接å��馈失败ã€�困惑和ä¸�顺手之处。既有的开å�‘阶段声明明确说明 DeepSeek Harness 处于内测阶段。
+
+用户入å�£ç« 节在已有的 Webã€�TUI å’Œ Headless å…¥å�£æ—�补充 ACP(Agent Client Protocol)自动化æœ�务器和 Python/JSON-RPC SDK。安装å�Žçš„ TUI ä»�å�ªéœ€æ‰§è¡Œä¸€æ�¡ `dsh` 命令;Web 说明è¦�求先构建当å‰�检出,å†�è¿�行 `dsh web`,并明确处ç�†è‡ªå®šä¹‰æˆ–å¤�用的检出路径。这两æ�¡å�¯åŠ¨è·¯å¾„å¿…é¡»åˆ†åˆ«èƒ½åœ¨çœŸå®ž PTY 与生产构建ï¼�HTTP 冒烟ä¸åŽŸæ ·æ‰§è¡Œã€‚èƒ½åŠ›æ®µè�½æ²¿ç”¨ç®€æ´�清å�•的写法,补充已ç»�交付的 PTYã€�LSPã€�Webã€�ç›®æ ‡ã€�规划ã€�任务ã€�沙箱ã€�审批ã€�设置ã€�凿�®ã€�会è¯�查询和é�¥æµ‹ç‰èƒ½åŠ›ç±»åˆ«ï¼Œå¹¶è¯´æ˜Žä¸�å�Œç»„å�ˆå�ªé€‰ç”¨å…¶ä¸ä¸€éƒ¨åˆ†ã€‚相邻的一æ�¡åˆ—表项说明æ�ƒå¨�会è¯�æ—¥å¿—è§„åˆ™ï¼Œå› ä¸ºæŒ�久化ã€�回放ã€�查询ã€�é�¥æµ‹å’Œå�„类接å�£éƒ½ä¾�赖它。
+
+包(package)与æœ�务的完整清å�•ä»�ç”±å�„自的归属文档维护。ä¸è‹±æ–‡ README 采用相å�Œçš„æŠ€æœ¯ç»“æž„ï¼Œä½†ç¤¾åŒºç« èŠ‚ä»�分别指å�‘å�„自è¯è¨€å�—众的主è¦�交æµ�æ¸ é�“。文档网站继ç»ä½¿ç”¨ç‹¬ç«‹çš„用户指å�—首页。
+
+## 考虑过的替代方案
+
+**围绕新的产å“�å�™äº‹é‡�写 README。** 完整é‡�写能够çª�出所有现有入å�£å’Œèƒ½åŠ›ï¼Œä½†ä¹Ÿä¼šæ›¿æ�¢å‡†ç¡®ä¸”å·²ç»�è¿‡è¯„å®¡çš„æ–‡æ¡ˆï¼Œé€ æˆ�ä¸�å¿…è¦�çš„å�˜åŠ¨ã€‚çŽ°æœ‰äº‹å®žèƒ½å¤Ÿçº³å…¥æ—¢æœ‰çš„äº§å“�优先结构。
+
+**将仓库呈现为 SDK 和包清å�•。** è¿™æ ·èƒ½ç«‹å�³å±•现实现广度,å�´ä¼šè¿«ä½¿æ–°è¯»è€…从包å��å��推出产å“�。包索引与生æˆ�的能力图ä»�是æ�ƒå¨�清å�•。
+
+**使用包å�«æˆªå›¾ã€�å¾½ç« å’Œé‡�å¤�教程的长篇è�¥é”€é¡µé�¢ã€‚** 富媒体能够展示稳定的产å“�使用路径,但其内容会独立于命令和æº�ç �契约而é€�æ¸�é™ˆæ—§ã€‚æ ¹ README ä¿�æŒ�紧凑,并链接到å�¯è¿�行示例和å�„自维护的指å�—。
+
+**å°†æ ¹ README 投影为文档网站首页。** 使用å�Œä¸€ä¸ªé¦–页å�¯ä»¥é�¿å…�两套å�™äº‹ï¼Œä½†æ–‡æ¡£ç½‘站的用户指å�—与仓库é�¢å�‘产å“�和开å�‘者的入å�£åœ¨å¯¼èˆªå’Œç»´æŠ¤éœ€æ±‚上并ä¸�相å�Œã€‚
+
+## 结果
+
+评审者å�¯ä»¥åŒºåˆ†äº‹å®žæ›´æ–°ä¸Žç¼–辑性é‡�写;今å�Žçš„æ›´æ–°ä¼šä¿�留既有措辞,除é�žå…¶å�«ä¹‰å·²ç»�ä¸�å†�æ£ç¡®æˆ–完整。å�—å½±å“�的命令ã€�å…¥å�£ã€�å�‘布阶段声明或高层能力类别å�‘生å�˜åŒ–时,README ä»�é¡»å�Œæ¥æ›´æ–°ï¼›å®Œæ•´ç»†èŠ‚åˆ™ç»§ç»ä»¥é“¾æŽ¥æ–¹å¼�æ��供,而ä¸�是å¤�åˆ¶åˆ°æ£æ–‡ã€‚
diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml
new file mode 100644
index 0000000000..d65dae2802
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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-07-30-generated-third-party-notices.md
+2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043
+2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561
diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
new file mode 100644
index 0000000000..e480954d29
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
@@ -0,0 +1,53 @@
+# Agent Note: Generated third-party notices
+
+Status: implemented
+
+English | [䏿–‡](2026-07-30-generated-third-party-notices.zh.md)
+
+## Problem
+
+Open-sourcing this repository requires disclosing the third-party software it depends on, with each project's license. The disclosure has to be complete, has to stay true as dependencies change, and has to say something a reader can act on — which of these packages end up on a user's machine, and which only build and test the repository.
+
+A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing would notice.
+
+## Decision
+
+[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section.
+
+**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
+
+One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case.
+
+The file discloses **direct** dependencies only. 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.
+
+**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/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `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 `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).
+
+The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice.
+
+The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed.
+
+## Testing
+
+The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford.
+
+## Alternatives considered
+
+**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.
+
+**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost.
+
+**Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead.
+
+**Tier by manifest section (`dependencies` vs `devDependencies`).** Mechanically simple and wrong on real data in both directions, as the tiering paragraph above records.
+
+**Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice.
+
+**Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it.
+
+## Consequences
+
+A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command.
+
+The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy.
+
+The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime.
diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
new file mode 100644
index 0000000000..78ba7250e7
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
@@ -0,0 +1,53 @@
+# Agent Note: Generated third-party notices
+
+Status: implemented
+
+[English](2026-07-30-generated-third-party-notices.md) | 䏿–‡
+
+## Problem
+
+本仓库开�需�披露所�赖的第三方软件��自的许��。这份披露必须完整,必须��赖�化��为真,还必须给出读者用得上的信�:哪些包最终会进到用户机器上,哪些�用于构建和测试。
+
+手写清å�•æ— æ³•é•¿æœŸæ»¡è¶³å…¶ä¸ä»»ä½•一æ�¡ã€‚约一百行从å�„清å�•文件推导出æ�¥çš„包å��与许å�¯è¯�æ ‡è¯†ï¼Œå�ªè¦�有ä¾�赖新增ã€�移除或æ�¢ç”¨è®¸å�¯è¯�就会悄悄失真,而没有任何检查会察觉。
+
+## Decision
+
+[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) ç”± [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) ä¾�æ�®å�„工作区清å�•ã€�`vendor/README.md`ã€�`pyproject.toml` 与 `pnpm-workspace.yaml` 生æˆ�ã€‚æ ¹ README å�Œè¯ä¸¤ä¾§éƒ½ä»Žã€Œè®¸å�¯è¯�ã€�一节链到该文件。
+
+**新鲜度é� 维护而é�žæ‹¦æˆªã€‚** å�ªè¦�æš‚å˜äº†ç”Ÿæˆ�器的任一输入——任何清å�•文件ã€�工作区声明ã€�æ ¹é”�文件ã€�`vendor/README.md`ã€�æŸ�个 `pyproject.toml`ã€�生æˆ�器自身,或æŒ�有构建期 pin 的脚本——pre-commit 任务就会é‡�新生æˆ�并一并入库,改ä¾�赖的人ä¸�必事å�Žå†�折返跑一次生æˆ�器。已æ��交的å—节éš�å�Žç”± [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) æ–言,而测试 lane æœ¬å°±ä¼šè·‘è¿™ä¸ªæ–‡ä»¶â€”â€”è¿™é¡¹æ ¡éªŒä¸�å¢žåŠ é—¨ç¦�进程ã€�ä¸�å� 调度ä½�ã€�也ä¸�新增 CI æ¥éª¤ã€‚需è¦�å�•ç‹¬æ ¡éªŒæ—¶ï¼Œ`pnpm run verify-third-party-notices` ä»�ç„¶å�¯ç”¨ã€‚
+
+有一处触å�‘缺å�£æ˜¯æŽ¥å�—而é�žç»•过的:lefthook å�ªæ£€è§†ç£�盘上å˜åœ¨çš„æ–‡ä»¶ï¼Œå› æ¤**åˆ é™¤**清å�•文件ä¸�会触å�‘任何任务,移除一个包会è�½åˆ°æµ‹è¯• lane çš„æ–言上。é‡�æž„æš‚å˜æ–‡ä»¶åˆ—è¡¨ä»¥çº³å…¥åˆ é™¤çš„å�šæ³•è¯•è¿‡ï¼Œä¸�æˆ�ç«‹â€”â€”æ— è®ºæ€Žä¹ˆç»™åˆ—è¡¨ï¼Œlefthook éƒ½ä¼šæ‹¿å·¥ä½œæ ‘è¿‡æ»¤ä¸€é��。这个场景æ£ç”±æ–言兜底。
+
+文件å�ªæŠ«éœ²**直接**ä¾�赖。完整的 npm é—包连å�Œé”�定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` å�¯æ¸²æŸ“),Python é—包记录在 `python/sdk/uv.lock`ï¼›å†�用散文誊一é��å�ªä¼šå¾—到一份更差的副本。
+
+**分层ä¾�æ�®æ˜¯å£°æ˜Žæ–¹æ‰€åœ¨åŒºåŸŸï¼Œè€Œé�žæ¸…å�•å—æ®µå��。** å�ªè¦� `DEV_ONLY_AREAS` 之外的任一清å�•——å�³æ ¹æ¸…å�•ã€�`packages/support/`ã€�`packages/client/test-runtime/`ã€�`website/`ã€�`examples/`ã€�`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点å��æŸ�个包,它就是è¿�行时ä¾�赖。å�•çœ‹å—æ®µå��在两个方å�‘上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里å�´å¹¶ä¸�交付它;而 `bin/dsh` å�¯åЍ噍 exec ç»�过的 `tsx`ï¼Œæ ¹æœ¬æ²¡æœ‰ä»»ä½•æ¸…å�•把它声明为è¿�行时ä¾�赖,å�ªèƒ½ç”±ç”Ÿæˆ�器显å¼�æ ‡è®°ã€‚
+
+è¿�行时层刻æ„�覆盖**所有å�¯æŒ‚载的æ�’ä»¶**,而ä¸�æ¢ CLIã€�Web UI 与 Python è¿�è¡Œæ—¶é»˜è®¤åŠ è½½çš„é‚£äº›ã€‚`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` å�¯ä»¥æŒ‚载任何æ�’件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列å�³ä½¿æ²¡æœ‰ä»»ä½•默认装é…�引入,也会触达真实用户。对法务披露而言,披露ä¸�è¶³æ‰�是代价更高的那个方å�‘。
+
+清å�•集å�ˆç”±ä¸¤ä¸ª `pnpm-workspace.yaml`â€”â€”æ ¹å·¥ä½œåŒºä¸ŽåµŒå¥—çš„ Landlock 工作区——å�„自声明的 `packages:` æˆ�å‘˜æ´¾ç”Ÿï¼Œå› æ¤æ–°å¢žæˆ�员区域在声明当天就会被读å�–,而ä¸�å¿…ç‰è°�想起æ�¥åŽ»è¡¥ä¸€ä»½åˆ—è¡¨ã€‚è®¸å�¯è¯�与仓库地å�€å�–自已安装的 pnpm storeï¼Œæ ¹ store 与 Landlock 工作区的 store 都会查;æŸ�个包两处都解æž�ä¸�到时直接失败,而ä¸�是留下空å�•å…ƒæ ¼ã€‚`OVERRIDES` 收录已å�‘布清å�•ç”ä¸�上æ�¥çš„包:用 Rust 构建ã€�å�‘布时çœ�ç•¥ `license` å—æ®µçš„ npm å�¯æ‰§è¡ŒåŒ…,以å�Š `modelcontextprotocol/servers` 系列——该仓库æ£å¤„在 MIT å�‘ Apache-2.0 çš„é‡�新许å�¯è¿‡ç¨‹ä¸ï¼Œå®žé™…æ�¡æ¬¾æŒ‰è´¡çŒ®é€�æ�¡è€Œå®šã€‚è¿�行时ä¾�赖的许å�¯è¯�è‹¥ä¸�在宽æ�¾æ¸…å�•内å�³ä¸ºç¡¬å¤±è´¥ï¼šäº¤ä»˜ copyleft 是一项分å�‘决ç–,ä¸�该被一次é‡�新生æˆ�æ‚„æ‚„å�¸æ”¶ã€‚被æº�ç �收编的包会与 `vendor/README.md` 交å�‰æ ¸å¯¹ï¼Œå‡ºçްé�ž MIT å�³æŠ¥é”™ï¼›`pnpm-workspace.yaml` çš„ `patchedDependencies` 列在è¿�è¡Œæ—¶è¡¨æ ¼ä¹‹å�Žï¼Œå› 为 pnpm 在安装期就会打上这些补ä¸�——交付产物æ�ºå¸¦çš„æ˜¯æ”¹åŠ¨è¿‡çš„ `@earendil-works/pi-tui` 与 `node-pty`,补ä¸�文件本身就是改动的完整记录。
+
+## Testing
+
+æ–言新鲜度的å�Œä¸€ä¸ª spec 也用夹具清å�•é’‰ä½�分层规则,覆盖促æˆ�该规则的两个场景:测试支撑包的 `dependencies` æ�¡ç›®ï¼Œä»¥å�Šæ²¡æœ‰ä»»ä½•应用挂载的æ�’件包。它还把å�„è§£æž�器钉在那些原本会让æŸ�ä¸ªåŒ…æ— å£°æ¶ˆå¤±çš„å½¢æ€�上:ä¸�å†�覆盖全部收编目录的 `vendor/README.md` 表ã€�å�« extras çš„ä¾�赖数组(`"httpx[http2]"`)ã€�完全ä¸�带版本的ä¾�èµ–ã€�作者自å�–å��å—çš„ `[dependency-groups]` 表,以å�Šä»»ä½•硬编ç �列表都ä¸�å�«çš„工作区æˆ�员区域。这些都是é�™é»˜æ¼�æŠ¥è·¯å¾„â€”â€”æ£æ˜¯æŠ«éœ²æ–‡ä»¶æœ€æ‹…ä¸�起的失败方å¼�。
+
+## Alternatives considered
+
+**ä¿�留手写文件,å�‘版时人工过一é��。** 用肉眼审阅上百行推导数æ�®ï¼Œæ�°æ�°æ˜¯ç”Ÿæˆ�器能å�šå¯¹çš„æ´»ï¼›è€Œä¸”在两次å�‘版之间,文件自称「列出全部直接ä¾�èµ–ã€�è¿™å�¥è¯�æ— äººéªŒè¯�。
+
+**用专门的 `doc-sync` é—¨ç¦�æ ¡éªŒã€‚** 仓库里其他生æˆ�产物都是这么把关的,本次改动最åˆ�也是这个形æ€�。但它è¦�在本已冗长的矩阵里å†�å� 一个门ç¦�进程和一个调度ä½�;更糟的是,它唯一的失败方å¼�ï¼Œå°±æ˜¯åœ¨åˆ«äººæŽ¨å®Œä¸€ä¸ªæ— å…³çš„ä¾�èµ–å�‡çº§å‡ 分钟å�Žï¼Œé€šçŸ¥å¯¹æ–¹å›žåŽ»é‡�跑一次生æˆ�器。改为æ��交时é‡�新生æˆ�消除了这次打æ–,而把æ–言放进测试 lane 本就会跑的 spec 里,则以零é¢�外 CI æˆ�本ä¿�ä½�了这项ä¿�è¯�。
+
+**åˆ—å‡ºå®Œæ•´ä¼ é€’é—包。** é—包有数å�ƒä¸ªåŒ…,é”�文件里已带精确版本,铺开å�ªä¼šæ·¹æ²¡è¯»è€…真æ£è¦�评估的直接ä¾�赖。文件转而指å�‘é”�文件与 `pnpm licenses list`。
+
+**按清å�•å—æ®µåˆ†å±‚(`dependencies` 与 `devDependencies`)。** 机械上最çœ�事,但在真实数æ�®ä¸Šä¸¤ä¸ªæ–¹å�‘都会出错,ç�†ç”±è§�上文分层段è�½ã€‚
+
+**å�ªæŒ‰å·²äº¤ä»˜è£…é…�çš„å�¯è¾¾æ€§åˆ†å±‚**(`apps/*` åŠ `python/sdk-runtime`ï¼‰ã€‚è¿™æ ·å¾—åˆ°çš„è¿�行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开å�‘用途——而è¿�行已安装仓库的用户完全å�¯ä»¥æŒ‚载它们。这会低估披露,对法务通告æ�¥è¯´é”™åœ¨äº†æ›´å�±é™©çš„一侧。
+
+**把披露文件å�šæˆ�å�Œè¯å¯¹ã€‚** å…¶ä»–æ ¹æ–‡æ¡£éƒ½æ˜¯æˆ�对的,但这份文件是上游包å��ã€�SPDX æ ‡è¯†ä¸Žç½‘å�€æž„æˆ�çš„è¡¨æ ¼ï¼Œå�¯ç¿»è¯‘çš„å�ªæœ‰å¯¥å¯¥å‡ æ®µç« èŠ‚å¯¼è¯ã€‚`scripts/translation-pairing.ts` çš„å�‘现范围é™�定在 `README*`ã€�`.agents/notes/**`ã€�`docs/**` 与 `python/**`ï¼Œæ ¹ç›®å½•ä¸‹çš„é�ž README æ–‡ä»¶åœ¨æž„é€ ä¸Šå°±ä¸�属于å�Œè¯è¯æ–™ï¼›å�Œè¯å…¥å�£ç”± README 对承担。
+
+## Consequences
+
+æ¤å�Žæ”¹åЍä¾�赖时,é‡�新生æˆ�的披露文件会éš�å�Œä¸€ä¸ªæ��交入库。触å�Šæ¸…å�•文件的æ��交多付一次生æˆ�器è¿�行——约一秒;其余æ��交ä¸�å�—å½±å“�。若ç¦�用钩å�æ��交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。
+
+生æˆ�器需è¦�å·²å®‰è£…çš„å·¥ä½œæ ‘ï¼Œå› æ¤æ¯”纯æº�ç �生æˆ�器更é‡�ï¼›å�‘布元数æ�®ä¸�å�¯ç”¨çš„æ–°åŒ…需è¦�补一æ�¡ `OVERRIDES`,而ä¸�是默默渲染出空白许å�¯è¯�。这两类失败都会明确报错并指出补救方å¼�。
+
+分层规则是编ç �在一个常é‡�里的政ç–。若新增了ä¸�å�‚与交付的工作区区域——第二层测试基础设施ã€�å�¦ä¸€ä¸ªç«™ç‚¹â€”—就è¦�å�Œæ¥æ‰©å±• `DEV_ONLY_AREAS`,å�¦åˆ™å…¶ä¾�赖会被当作è¿�行时ä¾�赖披露出去。
diff --git a/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.i18n.yaml
new file mode 100644
index 0000000000..f690cc7a95
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md
+2026-07-31-capability-neutral-sandbox-policy-context.md: d45c6c652fb7a061c2f92b7e03c41cce39da2a30
+2026-07-31-capability-neutral-sandbox-policy-context.zh.md: 9a1bb7a13d934826dddf89b409b25ba892a31ea3
diff --git a/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md b/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md
new file mode 100644
index 0000000000..d45c6c652f
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md
@@ -0,0 +1,35 @@
+# Agent Note: Capability-neutral sandbox policy context
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-capability-neutral-sandbox-policy-context.zh.md)
+
+## Problem
+
+The current-policy context originally mirrored runtime composition through separate enforced-family and escalatable-family registries. Six backend, tool, and example call sites contributed `filesystem`, `bash`, or `terminal`; the policy service retained token sets for independent disposal, intersected and ordered the two registries, invalidated prompt assemblies on every lifecycle change, and tested every family combination.
+
+That inventory was neither needed to state the file policy nor authoritative for model-visible capability. A backend contribution could name a family whose model-facing tool was absent or hidden by request scope, while tool schemas already told the model which exact operations were available. The registries therefore widened the public service and lifecycle contract to maintain an approximate English sentence.
+
+## Decision
+
+`dsh-sandbox-policy` contributes one capability-neutral `sandbox:policy` context for every agent session. It derives the text only from `resolve({ session })`; there is no backend or tool family registration API, contribution map, ordering rule, or registration-driven prompt invalidation.
+
+The text conditions capability claims on available operations that the DSH file sandbox enforces. Under `read-only`, it states that such operations cannot modify files in the standing mode and tells the model to try an available tool normally, then follow any denial and escalation guidance that tool returns. Under `workspace-write`, it states the canonical session workspace and the qualified temporary-area allowance. Under `danger-full-access`, it states that the DSH file sandbox does not restrict file modifications by available operations.
+
+Tool schemas remain the authority for which operations are available. Tool results remain the authority for operation-specific denials and approved wider retries. Filesystem, one-shot bash, and terminal implementations continue to resolve and enforce the same per-call policy; only the redundant model-facing capability inventory is removed.
+
+This decision partially supersedes the family-registration and composition-conditioned wording in [the current sandbox policy context decision](../feature/2026-07-30-current-sandbox-policy-context.md). That note remains the owner of cache-safe context delivery, durable snapshot materialization, wording evidence, and the separation between guidance and enforcement.
+
+## Alternatives considered
+
+**Keep the registries but reduce their tests.** Rejected because the public methods, retained lifecycle state, six contribution sites, and approximate capability claim would remain. The combinatorial tests reflected the design cost; they did not create it.
+
+**Derive an exact inventory from the tool registry.** Rejected because current policy needs only a truthful conditional statement, while exact availability already appears in the assembled tool schemas and can vary by request scope. Mapping each schema back to an enforcement backend would introduce another derived relation with no current consumer.
+
+**Omit policy context unless a backend advertises enforcement.** Rejected because it recreates the registration problem and makes policy visibility depend on optional contributors. The conditional wording remains truthful even when no applicable operation is available.
+
+## Consequences
+
+The policy service has one owner-derived context path instead of two public registries and their disposal lifecycle. Capability additions and removals no longer churn the runtime-context snapshot, while mode and workspace changes still do. Exact mode wording, canonical roots, switching, resume, service disposal, and no-agent assembly remain covered; family-combination and contribution-lifecycle tests disappear with the behavior they protected.
+
+The model no longer receives a prose list of sandboxed capability families. It receives exact tool schemas plus one standing file-policy statement. If a future product needs a separate capability inventory, it must be derived from the authoritative per-request assembly rather than reconstructed through backend registration side channels.
diff --git a/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.zh.md b/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.zh.md
new file mode 100644
index 0000000000..9a1bb7a13d
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: ä¸�ä¾�赖具体能力的沙箱ç–略上下文
+
+Status: implemented
+
+[English](2026-07-31-capability-neutral-sandbox-policy-context.md) | 䏿–‡
+
+## 问题
+
+当å‰�ç–略上下文最åˆ�通过å�—强制执行家æ—�与å�¯å�‡æ�ƒå®¶æ—�两个独立注册表æ�¥æ˜ å°„è¿�行时组å�ˆã€‚å�Žç«¯ã€�工具与示例ä¸çš„å…个调用点会贡献 `filesystem`ã€�`bash` 或 `terminal`ï¼›ç–ç•¥æœ�务ä¿�ç•™ token 集å�ˆï¼Œä»¥ä¾¿ç‹¬ç«‹é‡Šæ”¾å�„项注册,对两个注册表的内容求交集并排åº�,在æ¯�次生命周期å�˜åŒ–时使æ��示è¯�组装失效,并且需è¦�测试覆盖所有家æ—�组å�ˆã€‚
+
+这份清å�•æ—¢ä¸�是说明文件ç–略所必需的,也ä¸�能作为模型å�¯è§�能力的æ�ƒå¨�ä¾�æ�®ã€‚å�Žç«¯è´¡çŒ®å�¯èƒ½å£°æ˜ŽæŸ�个家æ—�,而该家æ—�é�¢å�‘模型的工具并ä¸�å˜åœ¨ï¼Œæˆ–被请求作用域éš�è—�;工具 schema å·²ç»�å�‘模型准确说明哪些æ“�作å�¯ç”¨ã€‚å› æ¤ï¼Œä¸ºäº†ç»´æŠ¤ä¸€å�¥è¿‘似的英文说明,注册表扩大了公开æœ�务与生命周期契约。
+
+## 决ç–
+
+`dsh-sandbox-policy` 为æ¯�个 agent(智能体)会è¯�贡献一项ä¸�ä¾�赖具体能力的 `sandbox:policy` 上下文。上下文文本å�ªæ ¹æ�® `resolve({ session })` 派生;ä¸�å˜åœ¨å�Žç«¯æˆ–工具家æ—�注册 APIã€�è´¡çŒ®æ˜ å°„ã€�排åº�规则,也ä¸�ä¼šå› æ³¨å†Œå�˜åŒ–而使æ��示è¯�失效。
+
+该文本将能力声明é™�定于 DSH 文件沙箱所强制执行的å�¯ç”¨æ“�作。在 `read-only` 下,它说明这类æ“�作在常驻模å¼�ä¸‹æ— æ³•ä¿®æ”¹æ–‡ä»¶ï¼Œå¹¶æŒ‡ç¤ºæ¨¡åž‹æ£å¸¸å°�试å�¯ç”¨å·¥å…·ï¼Œéš�å�Žé�µå¾ªè¯¥å·¥å…·è¿”回的任何拒ç»�与å�‡æ�ƒå¼•导。在 `workspace-write` 下,它说明规范化的会è¯�工作区,以å�Šå¸¦æœ‰é€‚用æ�¡ä»¶çš„临时区域写入许å�¯ã€‚在 `danger-full-access` 下,它说明 DSH 文件沙箱ä¸�会é™�制å�¯ç”¨æ“�作修改文件。
+
+哪些æ“�作å�¯ç”¨ä»�以工具 schema 为准。针对具体æ“�作的拒ç»�以å�ŠèŽ·æ‰¹å�Žçš„æ›´å®½æ�¾æ¨¡å¼�é‡�试,ä»�以工具结果为准。文件系统ã€�一次性 bash 与终端实现继ç»è§£æž�并强制执行相å�Œçš„é€�调用ç–略;移除的å�ªæœ‰é�¢å�‘模型的冗余能力清å�•。
+
+本决ç–å�–代了[当å‰�沙箱ç–略上下文决ç–](../feature/2026-07-30-current-sandbox-policy-context.md)ä¸å…³äºŽå®¶æ—�注册与按组å�ˆæ�¡ä»¶åŒ–措辞的部分内容。缓å˜å®‰å…¨çš„上下文交付ã€�æŒ�久快照具体化ã€�措辞è¯�æ�®ï¼Œä»¥å�Šå¼•导与强制执行之间的边界,ä»�以该 Agent Note 为归属文档。
+
+## 曾考虑的替代方案
+
+**ä¿�留注册表,但å‡�少对应测试。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºå…¬å¼€æ–¹æ³•ã€�ä¿�留的生命周期状æ€�ã€�å…个贡献点和近似的能力声明ä»�会å˜åœ¨ã€‚组å�ˆæµ‹è¯•å�ªæ˜¯ä½“现了这一设计æˆ�本,并é�žæˆ�本的æˆ�å› ã€‚
+
+**从工具注册表派生确切清å�•。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºå½“å‰�ç–ç•¥å�ªéœ€è¦�一å�¥ç¬¦å�ˆäº‹å®žçš„æ�¡ä»¶æ€§å£°æ˜Žï¼Œè€Œç¡®åˆ‡çš„å�¯ç”¨æƒ…况已ç»�体现在组装å�Žçš„工具 schema ä¸ï¼Œå¹¶ä¸”å�¯èƒ½éš�请求作用域å�˜åŒ–。若å†�把æ¯�个 schema æ˜ å°„å›žå…¶å¼ºåˆ¶æ‰§è¡Œå�Žç«¯ï¼Œå°±ä¼šå¼•å…¥å�¦ä¸€é¡¹æ´¾ç”Ÿå…³ç³»ï¼Œè€Œå½“å‰�没有消费方需è¦�它。
+
+**仅在å�Žç«¯å£°æ˜Žä¼šå¼ºåˆ¶æ‰§è¡Œæ—¶æ��ä¾›ç–略上下文。** ä¸�äºˆé‡‡ç”¨ï¼Œå› ä¸ºè¿™ä¼šé‡�现注册问题,并使ç–略是å�¦å�¯è§�å�–决于å�¯é€‰è´¡çŒ®æ–¹ã€‚å�³ä½¿æ²¡æœ‰å�¯é€‚用的æ“�作,这段带æ�¡ä»¶çš„æŽªè¾žä»�然符å�ˆäº‹å®žã€‚
+
+## �果
+
+ç–ç•¥æœ�务å�ªæœ‰ä¸€æ�¡ç”±å½’å±žæ–¹ç›´æŽ¥æ´¾ç”Ÿçš„ä¸Šä¸‹æ–‡è·¯å¾„ï¼Œæ— éœ€ç»´æŠ¤ä¸¤ä¸ªå…¬å¼€æ³¨å†Œè¡¨å�Šå…¶é‡Šæ”¾ç”Ÿå‘½å‘¨æœŸã€‚å¢žåŠ æˆ–ç§»é™¤èƒ½åŠ›ä¸�å†�导致è¿�行时上下文快照å��å¤�å�˜åŒ–,模å¼�和工作区å�˜åŒ–ä»�会触å�‘快照更新。è�šç„¦æµ‹è¯•ä»�覆盖å�„模å¼�的确切措辞ã€�è§„èŒƒåŒ–æ ¹ç›®å½•ã€�模å¼�切æ�¢ã€�æ�¢å¤�ã€�æœ�åŠ¡é‡Šæ”¾ä¸Žæ— agent 组装;家æ—�组å�ˆä¸Žè´¡çŒ®ç”Ÿå‘½å‘¨æœŸæµ‹è¯•则éš�其所ä¿�护的行为一å�Œåˆ 除。
+
+模型ä¸�å†�收到以自然è¯è¨€åˆ—出的沙箱能力家æ—�清å�•,而是收到确切的工具 schema 与一项常驻文件ç–略声明。未æ�¥è‹¥äº§å“�需è¦�独立的能力清å�•,该清å�•必须从æ�ƒå¨�çš„é€�è¯·æ±‚ç»„è£…ç»“æžœä¸æ´¾ç”Ÿï¼Œä¸�能通过å�Žç«¯æ³¨å†Œè¿™ç§�æ—�路机制é‡�建。
diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml
new file mode 100644
index 0000000000..8cfbcd08c3
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.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-07-31-drop-user-message-edit-stub.md
+2026-07-31-drop-user-message-edit-stub.md: 5a34be1dd285f4aced9e6cfb2e324b20ec734bed
+2026-07-31-drop-user-message-edit-stub.zh.md: 768cb4618b4a85f61e6348f8c6fb6a93d686e7d3
diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md b/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md
new file mode 100644
index 0000000000..5a34be1dd2
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md
@@ -0,0 +1,27 @@
+# Agent Note: Drop the user-message edit stub
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-drop-user-message-edit-stub.zh.md)
+
+## Problem
+
+The user bubble's IconActions row carried an edit button beside copy and branch. Nothing backed it: the control had no click handler, no client mutation, and no host operation for resending an edited message. A user who found it saw an affordance the product cannot honor.
+
+## Decision
+
+`MessageIconActions` renders clock / copy / branch only, and its `edit` prop is gone with the button; `MessageItem` no longer passes it. The user bubble and the assistant chrome now differ only by clock side. The package README records the missing capability under Known Limitations, and the web message-actions golden pins the row without the control.
+
+The common locale keeps its generic `edit` term, which is shared vocabulary rather than this component's copy.
+
+Reintroduce the control together with the capability: a client mutation that edits a settled user message and the host behavior that decides what the edited message does to the turn that already consumed it.
+
+## Alternatives considered
+
+**Disable the button with a tooltip.** A visible-but-dead control still advertises editing and costs the same explaining; removal is the honest state.
+
+**Wire it to the queue editor.** The queue edits a message that has not been sent. A settled user message is already in the transcript and in the model's context, so reusing that editor would silently mean something else.
+
+## Consequences
+
+Web offers no way to correct a sent message; branching from the message is the nearest available gesture. Reintroduction is a UI-only change once the mutation exists, since the row composes its actions from props.
diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md b/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md
new file mode 100644
index 0000000000..768cb4618b
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md
@@ -0,0 +1,27 @@
+# Agent Note: 移除 user 消æ�¯çš„ç¼–è¾‘å˜æ ¹
+
+Status: implemented
+
+[English](2026-07-31-drop-user-message-edit-stub.md) | 䏿–‡
+
+## 问题
+
+user 气泡的 IconActions 行在å¤�制和分支æ—�边还有一个编辑按钮,但其背å�Žä»€ä¹ˆéƒ½æ²¡æœ‰ï¼šè¯¥æŽ§ä»¶æ²¡æœ‰ç‚¹å‡»å¤„ç�†ã€�没有 client ä¾§å�˜æ›´ï¼Œä¹Ÿæ²¡æœ‰ host ä¾§é‡�æ–°å�‘é€�已编辑消æ�¯çš„æ“�作。用户找到它时,看到的是一个产å“�æ— æ³•å…‘çŽ°çš„å�¯ä¾›æ€§ã€‚
+
+## 决ç–
+
+`MessageIconActions` å�ªæ¸²æŸ“æ—¶é’Ÿï¼�å¤�制ï¼�分支,其 `edit` prop éš�æŒ‰é’®ä¸€å¹¶åˆ é™¤ï¼›`MessageItem` ä¸�å†�ä¼ å…¥è¯¥ prop。现在 user 气泡与 assistant chrome å�ªåœ¨æ—¶é’Ÿä½�置上ä¸�å�Œã€‚包 README 在 Known Limitations ä¸è®°å½•这项缺失的能力,web çš„ message-actions é‡‘æ ·å›ºå®šäº†ä¸�å�«è¯¥æŽ§ä»¶çš„动作行。
+
+公共 locale �留通用的 `edit` ��:它是共享�汇,而�本组件的文案。
+
+�新引入该控件时�与能力一起�地:既需�编辑已定稿 user 消�的 client �更,也需� host 侧决定这�编辑�的消�对已�消费过它的轮次�味�什么。
+
+## 曾考虑的替代方案
+
+**把按钮置ç�°å¹¶åŠ æ��示。** 一个å�¯è§�ä½†æ— æ•ˆçš„æŽ§ä»¶ä»�在宣告å�¯ä»¥ç¼–辑,解释æˆ�本相å�Œï¼›ç›´æŽ¥ç§»é™¤æ‰�是诚实的状æ€�。
+
+**接到队列编辑器上。** 队列编辑的是尚未��的消�。已定稿的 user 消�已�进入转录和模型上下文,�用该编辑器会让�一个动作悄悄���一件事。
+
+## �果
+
+Web 没有任何途径修æ£å·²å�‘é€�的消æ�¯ï¼›ä»Žè¯¥æ¶ˆæ�¯åˆ†æ”¯æ˜¯æœ€æŽ¥è¿‘的现有手势。由于动作行的内容完全由 props 组å�ˆè€Œæ�¥ï¼Œèƒ½åŠ›å°±ç»ªå�Žé‡�新引入å�ªæ˜¯ä¸€æ¬¡çº¯ UI 改动。
diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml
new file mode 100644
index 0000000000..e4491b6c7b
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.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-07-31-one-route-to-add-a-workspace.md
+2026-07-31-one-route-to-add-a-workspace.md: 0d3f4096b11ec2d0bd1339268bfb8567380d3e1a
+2026-07-31-one-route-to-add-a-workspace.zh.md: b9b25b15680cb54ae980e217e6218a91cd1e366a
diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md
new file mode 100644
index 0000000000..0d3f4096b1
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md
@@ -0,0 +1,57 @@
+# Agent Note: One route to add a Workspace
+
+Status: implemented
+
+English | [䏿–‡](2026-07-31-one-route-to-add-a-workspace.zh.md)
+
+## Problem
+
+Both Workspace surfaces — the sidebar region header's `+` and the conversation hero's chip — offered two ways to get a Workspace: **Open local folder…**, which raised the composed directory flow, and **Create a new workspace**, which took a name and created `
/`. The two overlapped: the browse occupant carries its own **New folder** affordance, so picking a directory already covered creating one. Two entries meant two vocabularies for one outcome, a name dialog with its own duplicate-name rule, and a create target the operator could neither see nor choose.
+
+Removing the weaker entry leaves the sidebar header with exactly one action, which raised the presentation question this Note also settles: what a popover with a single row should look like.
+
+## Decision
+
+Adding a Workspace has one route: pick a host directory through the composed directory flow, new or existing. `menu.addWorkspace` ("æ·»åŠ å·¥ä½œåŒºâ€¦" / "Add workspace…") is the entry; the create-by-name dialog and its `create.*` / `menu.createWorkspace` / `workspace.new` strings are gone. The label names the outcome, not the mechanism, because it is now the only door to that outcome — a user looking for "新建" must find it.
+
+**A menu exists to disambiguate between targets.** When the only entry left is the add action — the add-only sidebar surface, or the hero with an empty list — the anchor gesture *is* that action: the flow opens directly and no popover renders. A one-row popover costs a click and offers nothing to choose between. The rule is one predicate (`addIsTheOnlyEntry`) covering both surfaces rather than a per-surface special case.
+
+Two boundaries fall out of that rule and are part of it:
+
+- **An empty list is only final once the baseline lands.** While `phase` is `pending` the hero keeps its menu and loading status instead of jumping into a flow that the arriving workspaces would have made unnecessary. The add-only surface lists nothing and never waits.
+- **An unoccupied directory-flow hole leaves nothing to add with.** The sidebar header then renders no button at all rather than a dead one; the hero's menu keeps working as a picker over whatever is listed, and shows nothing when nothing is listed either — an empty popover would claim a choice that does not exist. This is the seam's documented no-flow default reaching its conclusion: with the occupant gone, so is the only creation affordance. The hero's anchor chip belongs to ui-conversation, so this package can suppress the popover but cannot hide the chip.
+
+The direct-open path carries the busy rule the menu entry states: while a pick is still being adopted (`flowBusy`), the anchor gesture is held exactly as the entry is disabled, so a late outcome cannot race a second flow.
+
+`WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`.
+
+## Wire and CLI residue
+
+The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI.
+
+## Testing
+
+`connectFreshWorkspace` — the helper every web e2e scenario boots through — stages `/workspace` and adopts it through the dialog's path editor, so the produced session cwd stays identical to what create-by-name produced and scenario goldens stay valid. Staging rather than creating in-dialog keeps the helper idempotent across the repeated connects a scenario may make (a second create of the same folder fails, and the create dialog holds the flow open on that failure). Creating a folder from inside the chooser — the other half of the same route — is covered by `workspace-management.e2e.ts`, which owns the focused coverage: two workspaces added on folders the dialog creates, a deleted title reused on a different directory, and the browser-dialog aria golden.
+
+`smoke-real.e2e.ts` is the one scenario booting the unpatched shipped tree, where the `-auto` row resolves per host; it now pins `-browse` through a `--config` overlay so the developer's display environment cannot decide whether the picker is drivable at all.
+
+## Alternatives considered
+
+**Keep `Open local folder…` as the label.** Rejected: after the merge the entry both opens and creates, and naming it after the mechanism hides the creation half from exactly the users whose entry we removed. The counter-argument — "本地" usefully disambiguates the browser's machine from the harness's — is answered one step later by the dialog's own title and breadcrumbs.
+
+**Keep the two-entry menu and make `Create a new workspace` open the same flow.** Rejected: two labels for one action is the confusion we were asked to remove, not a smaller version of it.
+
+**Keep a one-row popover for consistency with the hero's menu.** Rejected: a popover that offers no choice is a wasted click and reads as unfinished. Consistency here is the *rule* (menu ⇔ a choice exists), not the widget.
+
+**Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now.
+
+**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten.
+
+**Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal.
+
+## Consequences
+
+- Creating a Workspace outside the operator's chosen directory is no longer possible from the UI; the server-controlled `--workspace-root` target was the one way to constrain where new workspace folders land, and nothing replaces it. A deployment that needs that constraint has to re-introduce it deliberately.
+- The one remaining route browses the host filesystem, so the picker's reach is now the whole host rather than one configured parent. That is already the browse occupant's contract; this change makes it the only contract.
+- A composition that mounts `ui-workspace` without any directory-picker package can no longer add a Workspace at all, and now says so by omitting the button instead of offering a create-by-name fallback.
+- The hero chip still announces `aria-haspopup="menu"` while the direct-open path raises a dialog instead. Making that truthful means routing the flow's presentation choice up through the `conversation.hero.workspace` owner contract — the flow owns the decision, the chip owns the announcement, and they sit in different packages — so it is a named follow-up rather than a silent inconsistency. The sidebar button this change added makes no popup claim at all.
diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md
new file mode 100644
index 0000000000..b9b25b1568
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md
@@ -0,0 +1,57 @@
+# Agent Note: One route to add a Workspace
+
+Status: implemented
+
+[English](2026-07-31-one-route-to-add-a-workspace.md) | 䏿–‡
+
+## Problem
+
+两处 Workspace 表层——侧边æ �区头的 `+` 与会è¯�主视觉区的 chip——都æ��供了两æ�¡èŽ·å¾— Workspace 的路径:**打开本地文件夹…** 拉起组å�ˆçš„目录æµ�程,**新建工作区** 接收一个å��称并创建 `/`。两者é‡�å� :æµ�览å� 用者自带 **新建文件夹** èƒ½åŠ›ï¼Œå› æ¤"选一个目录"本就覆盖了"建一个目录"。两个入å�£æ„�味ç�€å�Œä¸€ç»“果有两套è¯�汇ã€�一个自带é‡�å��规则的å��称对è¯�框,以å�Šä¸€ä¸ªæ“�作者既看ä¸�到也选ä¸�了的创建ä½�置。
+
+åˆ æŽ‰è¾ƒå¼±çš„é‚£ä¸ªå…¥å�£å�Žï¼Œä¾§è¾¹æ �区头å�ªå‰©ä¸€ä¸ªåŠ¨ä½œï¼ŒäºŽæ˜¯å¼•å‡ºäº†æœ¬ Note 一并è£�定的展示问题:å�ªæœ‰ä¸€è¡Œçš„æµ®å±‚åº”è¯¥é•¿ä»€ä¹ˆæ ·ã€‚
+
+## Decision
+
+æ·»åŠ Workspace å�ªæœ‰ä¸€æ�¡è·¯å¾„:通过组å�ˆçš„目录æµ�程选一个宿主机目录,新建的或已å˜åœ¨çš„都å�¯ä»¥ã€‚å…¥å�£æ˜¯ `menu.addWorkspace`("æ·»åŠ å·¥ä½œåŒºâ€¦" / "Add workspace…");按å��称创建的对è¯�框å�Šå…¶ `create.*`ï¼�`menu.createWorkspace`ï¼�`workspace.new` æ–‡æ¡ˆå…¨éƒ¨åˆ é™¤ã€‚æ ‡ç¾å‘½å��的是结果而é�žæœºåˆ¶ï¼Œå› 为它现在是通往该结果的唯一一扇门——找"新建"的用户必须能找到它。
+
+**è�œå�•çš„å˜åœ¨æ˜¯ä¸ºäº†åœ¨å¤šä¸ªç›®æ ‡ä¹‹é—´æ¶ˆæ§ã€‚** 当å�ªå‰©æ·»åŠ è¿™ä¸€ä¸ªå…¥å�£æ—¶â€”â€”ä»…æ·»åŠ çš„ä¾§è¾¹æ �表层,或列表为空的主视觉区——锚点手势*就是*该动作:直接拉起æµ�程,ä¸�渲染浮层。å�ªæœ‰ä¸€è¡Œçš„æµ®å±‚多花一次点击,å�´æ²¡æœ‰ä»»ä½•å�¯é€‰é¡¹ã€‚è¿™æ�¡è§„则是一个谓è¯�(`addIsTheOnlyEntry`)覆盖两处表层,而ä¸�是é€�表层特判。
+
+由该规则派生出两�边界,它们�属这个决定:
+
+- **列表为空å�ªæœ‰åœ¨åŸºçº¿è�½åœ°å�Žæ‰�算最终结果。** `phase` ä»�为 `pending` 时,主视觉区ä¿�ç•™è�œå�•ä¸ŽåŠ è½½çŠ¶æ€�,而ä¸�是跳进一个å�³å°†åˆ°è¾¾çš„ workspace 会使其å�˜å¾—多余的æµ�ç¨‹ã€‚ä»…æ·»åŠ è¡¨å±‚ä¸�åˆ—ä»»ä½•ä¸œè¥¿ï¼Œå› æ¤ä»Žä¸�ç‰å¾…。
+- **目录æµ�程的洞没有å� 用者时,就没有任何å�¯æ·»åŠ çš„æ‰‹æ®µã€‚** æ¤æ—¶ä¾§è¾¹æ �区头干脆ä¸�渲染按钮,而ä¸�是留下一个点了没å��应的按钮;主视觉区的è�œå�•则继ç»ä½œä¸ºé€‰æ‹©å™¨å·¥ä½œï¼Œåˆ—出已有内容——而当å�Œæ ·æ²¡æœ‰å†…容å�¯åˆ—时,它什么也ä¸�弹:空浮层会宣称一个并ä¸�å˜åœ¨çš„选择。这是 seam æ–‡æ¡£åŒ–çš„æ— æµ�程默认行为走到它的结论:å� 用者ä¸�在,唯一的创建能力也就ä¸�在。主视觉区的锚点 chip å½’ ui-conversation æ‰€æœ‰ï¼Œå› æ¤æœ¬åŒ…能压掉浮层,å�´æ— 法éš�è—�该 chip。
+
+直接拉起的这æ�¡è·¯å¾„å�Œæ ·é�µå®ˆè�œå�•项声明的 busy 规则:æŸ�次选å�–ä»�在接纳ä¸ï¼ˆ`flowBusy`)时,锚点手势会被按ä½�,æ£å¦‚该è�œå�•项会被ç¦�ç”¨ï¼Œä»Žè€Œä½¿è¿Ÿåˆ°çš„ç»“æžœæ— æ³•æŠ¢å¼€ç¬¬äºŒä¸ªæµ�程。
+
+`WorkspaceCreateFlow` 现更�为 `WorkspacePickFlow`,其 `createOnly` prop 更�为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。
+
+## Wire and CLI residue
+
+Host ä¾§çš„ `workspace.create` ä»�接å�— `{ name }`,`dsh web --workspace-root` 也ä»�在为它æ��ä¾›ç›®æ ‡ç›®å½•ï¼Œä½†å·²æ²¡æœ‰ä»»ä½•äº§å“�表层会走到它们。把å��ç§°é€�到 wire 的客户端一段å�Œæ ·å¦‚æ¤ï¼š`WorkspaceCreateInput`ã€�`WorkspacesService.create` çš„ `{ name }` 分支ã€�`intentName` çš„å��称分支,以å�Š manager ä¸"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` å�Šå…¶ä¸æ–‡å¯¹ç…§æœ¬ä¹Ÿä»�把 `--workspace-root` 记为"创建具å�� Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` çš„è°ƒç”¨ç‚¹æ ‡è®°ä¸ºå¾…åˆ é™¤ï¼Œå¹¶ç•™ç»™å�Žç»æ”¹åŠ¨ï¼šå®ƒæ¨ªè·¨ backendã€�客户端 seam 与 CLI é�¢ï¼Œæœ‰å�„自的 reviewer å’Œå�„自的测试波å�Šé�¢ï¼ˆapi-proxy workspace 套件ã€�runtime workspace 套件ã€�é…�置目录),而本决定ä¸é˜»å¡žå�‘布的部分是 UI。
+
+## Testing
+
+`connectFreshWorkspace`——所有 web e2e 场景å�¯åŠ¨æ—¶éƒ½ä¼šèµ°çš„è¾…åŠ©å‡½æ•°â€”â€”ä¼šé¢„å…ˆå¤‡å¥½ `/workspace`,å†�ç»�对è¯�æ¡†çš„è·¯å¾„ç¼–è¾‘å™¨æŽ¥çº³å®ƒï¼Œå› æ¤äº§å‡ºçš„会è¯� cwd 与按å��称创建时完全一致,场景 golden ä¿�æŒ�有效。选择预先备好而ä¸�是在对è¯�框内新建,是为了让该辅助函数在一个场景å�¯èƒ½å�‘生的多次连接之间ä¿�æŒ�幂ç‰ï¼ˆç¬¬äºŒæ¬¡åˆ›å»ºå�Œå��文件夹会失败,而创建对è¯�框会在失败时把æµ�程å�œåœ¨åŽŸåœ°ï¼‰ã€‚åœ¨é€‰æ‹©å™¨å†…æ–°å»ºæ–‡ä»¶å¤¹â€”â€”å�Œä¸€æ�¡è·¯å¾„çš„å�¦ä¸€å�Šâ€”—由 `workspace-management.e2e.ts` 覆盖,它承担针对性覆盖:在对è¯�æ¡†è‡ªå·±åˆ›å»ºçš„æ–‡ä»¶å¤¹ä¸Šæ·»åŠ ä¸¤ä¸ª workspaceã€�在å�¦ä¸€ä¸ªç›®å½•上å¤�ç”¨å·²åˆ é™¤çš„æ ‡é¢˜ã€�以å�Šæµ�览对è¯�框的 aria golden。
+
+`smoke-real.e2e.ts` 是唯一å�¯åŠ¨æœªæ‰“è¡¥ä¸�的出厂é…�ç½®æ ‘çš„åœºæ™¯ï¼Œå…¶ä¸ `-auto` 行会按宿主机解æž�;它现在通过 `--config` overlay é’‰æ» `-browse`,使开å�‘æœºçš„æ˜¾ç¤ºçŽ¯å¢ƒæ— æ³•å†³å®šé€‰æ‹©å™¨æ˜¯å�¦å�¯è¢«é©±åŠ¨ã€‚
+
+## Alternatives considered
+
+**ä¿�ç•™ `打开本地文件夹…` ä½œä¸ºæ ‡ç¾ã€‚** å�¦å†³ï¼šå�ˆå¹¶å�Žè¯¥å…¥å�£æ—¢èƒ½æ‰“开也能创建,用机制命å��会æ�°å¥½å¯¹é‚£äº›å…¥å�£è¢«æˆ‘ä»¬åˆ æŽ‰çš„ç”¨æˆ·éš�è—�创建这一å�Šã€‚å��æ–¹ç�†ç”±â€”—"本地"äºŒå—æœ‰æ•ˆåŒºåˆ†äº†æµ�览器所在机器与 harness 所在机器——在下一æ¥å°±ç”±å¯¹è¯�æ¡†è‡ªèº«çš„æ ‡é¢˜å’Œé�¢åŒ…屑回ç”了。
+
+**ä¿�ç•™å�Œå…¥å�£è�œå�•,让 `新建工作区` 也打开å�Œä¸€ä¸ªæµ�程。** å�¦å†³ï¼šå�Œä¸€åŠ¨ä½œä¸¤ä¸ªæ ‡ç¾æ£æ˜¯æˆ‘们被è¦�求消除的混淆,而ä¸�是它的缩å°�版。
+
+**为了与主视觉区è�œå�•ä¿�æŒ�一致而ä¿�ç•™å�ªæœ‰ä¸€è¡Œçš„æµ®å±‚。** å�¦å†³ï¼šä¸�æ��供选择的浮层是一次浪费的点击,读起æ�¥åƒ�å�Šæˆ�å“�。这里è¦�一致的是*规则*(有è�œå�• ⇔ å˜åœ¨é€‰æ‹©ï¼‰ï¼Œä¸�是控件。
+
+**为将æ�¥å�¯èƒ½æ–°å¢žçš„å…¥å�£ï¼ˆå…‹éš†ä»“库ã€�远程目录)ä¿�ç•™è�œå�•壳。** å�¦å†³ï¼Œä¾�æ�®"require a current owner and need"ï¼šè¿™æ ·çš„å…¥å�£ç›®å‰�å¹¶ä¸�å˜åœ¨ï¼Œè€Œç‰å®ƒåˆ°æ�¥æ—¶å†�æ�¢å¤�è�œå�•,比现在就å�‘一个空壳的改动更å°�。
+
+**在å�Œä¸€æ”¹åЍä¸åˆ 除 wire 的按å��称创建分支。** 本 PR å�¦å†³ï¼šé‚£æ˜¯ backend/CLI é�¢ï¼Œreviewer ä¸�å�Œã€�测试波å�Šé�¢æ›´å¹¿ï¼Œè€Œç´§æ€¥çš„决定是 UI。è§� residue ä¸€èŠ‚â€”â€”å®ƒæ˜¯è¢«æ ‡è®°äº†ï¼Œä¸�是被é�—忘了。
+
+**在 e2e scaffold ä¸ç»� host 注册 workspace,而ä¸�驱动对è¯�框。** å�¦å†³ï¼šé‚£ä¼šè®©å…¨éƒ¨ 15 个场景与选择器解耦,整æ�¡ lane å°†æ— æ³•è¯�明幸å˜çš„è¿™æ�¡è·¯å¾„能走到å�¯ç”¨çš„ composer。现在æ¯�个场景都会走真实对è¯�框æ�¥æŽ¥çº³è‡ªå·±çš„目录;å�ªæœ‰"新建文件夹"那一å�Šé›†ä¸åœ¨ä¸€ä¸ªåœºæ™¯é‡Œï¼Œå› 为处处é‡�å¤�å�ªä¼šè®©å…±äº«è¾…åŠ©å‡½æ•°å¤±åŽ»å¹‚ç‰æ€§ï¼Œå�´æ�¢ä¸�æ�¥é¢�外信å�·ã€‚
+
+## Consequences
+
+- 从 UI å·²æ— æ³•åœ¨æ“�作者选定目录之外创建 Workspaceï¼›æœ�务端控制的 `--workspace-root` ç›®æ ‡æ›¾æ˜¯çº¦æ�Ÿæ–° workspace 文件夹è�½ç‚¹çš„唯一手段,现在没有替代å“�。需è¦�该约æ�Ÿçš„部署必须有æ„�识地é‡�新引入它。
+- ä»…å˜çš„è¿™æ�¡è·¯å¾„会æµ�è§ˆå®¿ä¸»æœºæ–‡ä»¶ç³»ç»Ÿï¼Œå› æ¤é€‰æ‹©å™¨çš„å�¯è¾¾èŒƒå›´çŽ°åœ¨æ˜¯æ•´å�°å®¿ä¸»æœºï¼Œè€Œé�žä¸€ä¸ªé…�置好的父目录。这本就是æµ�览å� 用者的契约,本改动使它æˆ�为唯一的契约。
+- 挂载了 `ui-workspace` 但未挂任何 directory-picker 包的组å�ˆï¼Œå·²å®Œå…¨æ— æ³•æ·»åŠ Workspace;现在它通过ä¸�渲染按钮æ�¥è¯´æ˜Žè¿™ä¸€ç‚¹ï¼Œè€Œä¸�是æ��供一个按å��称创建的兜底。
+- 主视觉区的 chip ä»�声明 `aria-haspopup="menu"`,而直接拉起的路径实际弹出的是对è¯�框。è¦�让它如实,需è¦�把æµ�程的展示方å¼�ç»� `conversation.hero.workspace` çš„ owner 契约上报——决定æ�ƒåœ¨æµ�ç¨‹ï¼Œæ’æŠ¥æ�ƒåœ¨ chip,两者分属ä¸�å�Œçš„åŒ…â€”â€”å› æ¤è¿™è¢«åˆ—为一项具å��çš„å�Žç»å·¥ä½œï¼Œè€Œä¸�æ˜¯ä¸€å¤„æ— å£°çš„ä¸�一致。本次新增的侧边æ �按钮完全ä¸�作任何 popup 声明。
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
index ada8ff7a3e..91d77fb803 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 107dbddbfde8ad29e22d9cba04ce2b83c1d01383
-2026-07-24-web-gui-browser-e2e-lane.zh.md: e4132b2ebb3f30a9d540f47cf9416a13bc4aa9f3
+2026-07-24-web-gui-browser-e2e-lane.md: cdb7de52c50733d6650202ee2117916319940738
+2026-07-24-web-gui-browser-e2e-lane.zh.md: b850acf026502d054a9d8b2168f0b4f47f58f39b
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
index 107dbddbfd..cdb7de52c5 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
@@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
-Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; `settings-chrome.e2e.ts` alone leaves storage unset to cover the default Chinese state and both switch directions.
+Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default.
### Expected outputs
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
index e4132b2ebb..b850acf026 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
@@ -28,7 +28,7 @@ Web GUI 以一�真实组装链交付——chromium 页� → client �件 bu
ä¸�å�šå�•次瞬æ€� DOM æ–言:从回放产出到 React æ��交的æ¯�一跳都å�¯èƒ½å�ˆå¹¶åˆ†ç‰‡ï¼Œé‡‡æ · `[data-streaming]` 天然就是竞æ€�。æµ�å¼�输出的增é‡�性由æŒ�久化的 `assistant/chunk` 事件æ–言(模型å�¯è§� ⟺ 已记录,使日志æˆ�为æ�ƒå¨�è¯�æ�®ï¼‰ã€‚`dsh-llm-replay` çš„å�¯é€‰ `paceMs`(默认缺çœ� = çª�å�‘)å�ªæ˜¯è®©æµ�览器观察到真æ£å¢žé‡� SSE 的真实感旋钮;æ£ç¡®æ€§ç»�ä¸�ä¾�赖它,且节å¥�ç‰å¾…æœŸé—´ä¸æ¢ä¼šå�³æ—¶å�–消。
-æ¯�ä¸ªåœºæ™¯éƒ½ä¼šå› ä»»ä½• pageerror 或客户端的连接丢失/间隙修å¤�控制å�°è¦å‘Šè€Œå¤±è´¥ï¼šå�¦åˆ™é‡�è¿žæœºåˆ¶åŠ åŽ†å�²é‡�å�Œæ¥ä¼šæŠŠä¸€æ�¡æ»æŽ‰çš„ SSE 通路自愈掉,套件å��而认è¯�了å�� wire。Scaffold çš„ `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(æ¯�个已录脚本都被绑定ã€�æ¯�ä¸ªæ¸¸æ ‡éƒ½è€—å°½ï¼‰ï¼ŒæŠŠé�™é»˜çš„少放与错绑å�˜æˆ�清晰诊æ–。车é�“ä¸�设 vitest é‡�试;æ¯�文件一个 chromiumã€�æ¯�场景一个新 contextã€�æ¯�场景一个 host;视å�£å›ºå®šï¼›äº¤äº’选择器锚定 roleã€�`data-*` 属性和å�¯è§�文本,而 frame 与会è¯�区采集则使用既有的 CSS 模å�—局部类å��锚点。常规场景在客户端å�¯åЍå‰�设置 `dsh.locale=en`,使本地化的 role 定ä½�器和预期输出统一采用明确指定的è¯è¨€ï¼›å�ªæœ‰ `settings-chrome.e2e.ts` ä¸�预设该å˜å‚¨é¡¹ï¼Œä»¥è¦†ç›–é»˜è®¤ä¸æ–‡çжæ€�å�Šå�Œå�‘切æ�¢ã€‚
+æ¯�ä¸ªåœºæ™¯éƒ½ä¼šå› ä»»ä½• pageerror 或客户端的连接丢失/间隙修å¤�控制å�°è¦å‘Šè€Œå¤±è´¥ï¼šå�¦åˆ™é‡�è¿žæœºåˆ¶åŠ åŽ†å�²é‡�å�Œæ¥ä¼šæŠŠä¸€æ�¡æ»æŽ‰çš„ SSE 通路自愈掉,套件å��而认è¯�了å�� wire。Scaffold çš„ `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(æ¯�个已录脚本都被绑定ã€�æ¯�ä¸ªæ¸¸æ ‡éƒ½è€—å°½ï¼‰ï¼ŒæŠŠé�™é»˜çš„少放与错绑å�˜æˆ�清晰诊æ–。车é�“ä¸�设 vitest é‡�试;æ¯�文件一个 chromiumã€�æ¯�场景一个新 contextã€�æ¯�场景一个 host;视å�£å›ºå®šï¼›äº¤äº’选择器锚定 roleã€�`data-*` 属性和å�¯è§�文本,而 frame 与会è¯�区采集则使用既有的 CSS 模å�—局部类å��锚点。常规场景在客户端å�¯åЍå‰�设置 `dsh.locale=en`,使本地化的 role 定ä½�器和预期输出统一采用明确指定的è¯è¨€ï¼›æ–è¨€ä¸æ–‡æ–‡æ¡ˆçš„场景则ä¸�预设该å˜å‚¨é¡¹ï¼Œæ”¹ä¸ºå¼€å�¯ `zh-CN` æµ�è§ˆå™¨ï¼Œå› ä¸ºå®¢æˆ·ç«¯çš„åˆ�å§‹ locale ç”± `navigator` 推导([ç”±æµ�览器推导åˆ�å§‹ locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还é¢�外覆盖å�Œå�‘切æ�¢ä¸Žè‹±æ–‡æµ�览器默认æ€�。
### 预期输出
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
index 9ce321434f..0c5c704684 100644
--- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
-2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391
-2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278
+2026-07-28-storage-root-and-derived-medium-recovery.md: 2edb60d204da736bf14ace6278fa4ee7b9e4b8f7
+2026-07-28-storage-root-and-derived-medium-recovery.zh.md: daef4f7661df069df3438f909f2744880a93a873
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
index 3937b17502..2edb60d204 100644
--- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
@@ -8,7 +8,7 @@ English | [䏿–‡](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
-**Where the files actually live.** The shipped Web overlay gives the json backend a relative root — `root: './.storages'` (`apps/cli/config/web.cordis.yml`) — while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
+**Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts).
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
@@ -16,11 +16,11 @@ The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-
Two independent changes, one per gap.
-### One global storage root, resolved once
+### One global storage root (shipped, amended form); resolved once at construction (still open)
-- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
-- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
-- Pre-release stance applies: no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
+- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row through the app-boot-provided `dshHomePath('storages')` (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The helper delegates to the canonical `dsh-paths` resolver, and the session root uses the same function without duplicating its fallback and tilde rules. The user ruled the per-row form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home.
+- **Still open**: `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
+- Pre-release stance applies (and was executed): no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
### Declared derived media: reset instead of reject
@@ -31,7 +31,9 @@ Two independent changes, one per gap.
## Alternatives considered
-**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
+**Keep per-launch-directory `.storages` (the pre-change status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
+
+**Launcher patch + a `storageRoot` profile key (this section's original form)** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer).
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
@@ -45,7 +47,7 @@ Two independent changes, one per gap.
## Acceptance criteria
-- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
+- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`) — already satisfied by the overlay expression; per-row overrides ride the personal config.yaml patch layer; the backend resolves a relative root once at construction (still to do).
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
- The same damage to `workspace.json` still fails boot loudly.
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
index ff2f196a91..daef4f7661 100644
--- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
@@ -8,7 +8,7 @@ Status: proposed
æŒ�久投影缓å˜ï¼ˆ[RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` è�½åœ°ï¼‰æš´éœ²äº†å®ƒæ‰€ä¾�托的å˜å‚¨åŸºåº§çš„两个缺å�£ã€‚二者都是 domain-KV æ ˆï¼ˆ[设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而é�žç¼“å˜è‡ªèº«çš„问题,且都首先咬到缓å˜â€”â€”å› ä¸ºå®ƒæ˜¯è¿™æ�¡æ ˆä¸Šç¬¬ä¸€ä¸ª*派生*介质。
-**文件到底å˜åœ¨å“ªã€‚** 出厂 Web overlay ç»™ json å�Žç«¯çš„æ˜¯ç›¸å¯¹æ ¹ç›®å½•——`root: './.storages'`(`apps/cli/config/web.cordis.yml`)——而共享 base 将会è¯�å˜å‚¨é»˜è®¤ä¸ºå…¨å±€ harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`);`storage-json` æ²¡æœ‰å¯¹åº”çš„å…¨å±€æ ¹ç›®å½•ã€‚`JsonStorageBackend` 自己也从ä¸� resolve æ ¹â€”â€”æ¯�次打开 unit 都把ä»�然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.tsï¼‰â€”â€”è¿™æ£æ˜¯ JSONL 会è¯�å�Žç«¯ç”¨ã€Œæž„é€ æ—¶ resolve 一次ã€�防ä½�的那个éš�患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会è¯�日志跨å�¯åŠ¨ç›®å½•å…¨å±€å…±äº«ï¼Œä½† `workspace.json` å’Œ `session_projcache.json` è�½åœ¨ `<å�¯åŠ¨ç›®å½•>/.storages/` 下。从两个ä¸�å�Œç›®å½•å�¯åŠ¨ï¼Œä¼šè¯�相å�Œï¼Œå·¥ä½œåŒºæ³¨å†Œè¡¨å’ŒæŠ•影缓å˜å�´å�„是一份——而缓å˜å˜åœ¨çš„æ„�义æ�°æ�°æ˜¯è·¨ä¼šè¯�冷列表,如今凡是上次在别的å�¯åŠ¨ç›®å½•ä¸‹ç¼“å˜è¿‡çš„会è¯�全部 miss。
+**文件到底å˜åœ¨å“ªï¼ˆæ ¹é”™ä½�已收å�£ï¼Œresolve-once 残余ä»�开放)。** 共享 base 将会è¯�å˜å‚¨é»˜è®¤ä¸ºå…¨å±€ harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json å�Žç«¯ç›¸å¯¹æ ¹ `./.storages`:`workspace.json` å’Œ `session_projcache.json` è�½åœ¨ `<å�¯åŠ¨ç›®å½•>/.storages/` 下——从两个ä¸�å�Œç›®å½•å�¯åŠ¨ï¼Œä¼šè¯�相å�Œï¼Œå·¥ä½œåŒºæ³¨å†Œè¡¨å’ŒæŠ•影缓å˜å�´å�„是一份,而缓å˜å˜åœ¨çš„æ„�义æ�°æ�°æ˜¯è·¨ä¼šè¯�冷列表,凡上次在别的å�¯åŠ¨ç›®å½•ä¸‹ç¼“å˜è¿‡çš„会è¯�全部 miss。这一错ä½�已消除:overlay 现以与会è¯�æ ¹å�Œä¸€æ®µ `!!js` 表达å¼�把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余éš�患:`JsonStorageBackend` ä»�从ä¸� resolve æ ¹â€”â€”æ¯�次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay çš„æ ¹å·²æ˜¯ç»�对路径ä¸�å�—å½±å“�ï¼Œä½†ä»»ä½•ç›¸å¯¹æ ¹ï¼ˆè£¸ Loader å�¯åЍã€�测试)ä»�会被å�Žç» cwd å�˜åŒ–劈开,JSONL 会è¯�å�Žç«¯ç”¨ã€Œæž„é€ æ—¶ resolve 一次ã€�防ä½�çš„æ£æ˜¯å®ƒï¼ˆ"later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。
**现在是怎么æ�¢å¤�的。** 在å�¥åº·ä»‹è´¨å†…éƒ¨ï¼Œç¼“å˜æŒ‰è®¾è®¡å®Œå…¨è‡ªæ„ˆï¼š`stateVersion` ä¸�匹é…�的行被丢弃é‡�折,日志缩çŸåˆ°è¡Œæ°´ä½�以下由带锚的 restore floor 检出并以一次全é‡�é‡�读回ç”,æ¯�次å�Žå�°å†™éƒ½æ˜¯ fail-soft。但在*介质*层é�¢å®Œå…¨æ²¡æœ‰æ�¢å¤�:被截æ–ã€�被手改或版本被 bump çš„ `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒ç»�一路穿过 `SessionProjectionCache[Service.init]`,在 CLI çš„ fail-loud å�¯åŠ¨ä¸‹æ•´ä¸ªç»„è£…æ‹’ç»�å�¯åŠ¨ã€‚ä¸€ä¸ªå†…å®¹å®Œå…¨å�¯ä»Žä¼šè¯�日志é‡�建的文件能把å�¯åЍæ�žæ»ã€‚这与缓å˜åŒ…自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓å˜åŸŸ spec çš„ JSDoc("version bumps discard the whole medium")相矛盾——å�Žè€…今天æ��述的是愿望而é�žå®žçŽ°ã€‚å�Œä¸€æ�¡ fail-loud 路径对 `workspace.json` å�´æ˜¯*æ£ç¡®*的——工作区记录是æ�ƒå¨�æ•°æ�®ï¼Œä¸�å�¯æ´¾ç”Ÿâ€”—所以缺的概念是按域声明æ�ƒå¨�性,而ä¸�是全局改行为。
@@ -16,11 +16,11 @@ Status: proposed
两个独立改动,一个缺�一个。
-### 全局唯一å˜å‚¨æ ¹ï¼Œæž„é€ æ—¶ resolve 一次
+### 全局唯一å˜å‚¨æ ¹ï¼ˆå·²è�½åœ°ï¼Œå½¢æ€�ä¿®æ£ï¼‰ï¼›æž„é€ æ—¶ resolve 一次(ä»�开放)
-- `AppCLIEntry.composePatches` çš„ Source 0 è¿½åŠ æŠŠ `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` å¢žåŠ `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜åƒ�。yml ä¿�ç•™ `./.storages` 作为裸组å�ˆçš„工程默认(测试和裸 Loader å�¯åЍä¸�å�—å½±å“�),分层方å¼�与今天的会è¯�æ ¹ç›¸å�Œã€‚
-- `JsonStorageBackend` åœ¨æž„é€ æ—¶å¯¹é…�ç½®æ ¹ `resolve` ä¸€æ¬¡ï¼ŒåŽŸæ ·é‡‡çº³ JSONL å�Žç«¯å·²è®°å½•çš„ç�†ç”±ï¼šå�Žç» `process.cwd()` å�˜åŒ–ä¸�得把一个å�Žç«¯åŠˆåˆ°å¤šä¸ªæ ¹ä¸‹ã€‚SQLite å˜å‚¨å�Žç«¯å·²ç»� resolve 其路径。
-- 适用 pre-release 立场:ä¸�å�šè¿�移垫片。曾在 `/.storages` 下缓å˜è¿‡çš„部署è¦�么全部é‡�新派生(工作区从 header 索引é‡�æ–° bootstrapï¼›æŠ•å½±ç¼“å˜æƒ°æ€§é‡�折),è¦�么手动把两个 json 文件挪一次。
+- **å·²è�½åœ°**:出厂 Web overlay 通过 app-boot æ��供的 `dshHomePath('storages')`,直接在 `storage-json` 行内把 `root` 锚定到 `$DSH_HOME/storages`(默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩;目录å��ä¸�带点——home 本身已是éš�è—�æ ‘ï¼‰ã€‚è¯¥è¾…åŠ©å‡½æ•°å§”æ‰˜ç»™è§„èŒƒçš„ `dsh-paths` è§£æž�器,会è¯�æ ¹ä¹Ÿä½¿ç”¨å�Œä¸€ä¸ªå‡½æ•°ï¼Œæ— 需é‡�å¤�其回退和波浪å�·è§„则。用户æ‹�æ�¿é‡‡ç”¨è¿™ç§�按行形æ€�å�–代本节åˆ�版的「launcher patch + `storageRoot` profile é”®ã€�(è§� Alternatives);按行覆盖ä»�走个人 `~/.dsh/config.yaml` patch 层。web e2e scaffold 本就把该行 patch 到临时ç»�å¯¹æ ¹ï¼Œæµ‹è¯•ä¸�触用户 home。
+- **ä»�开放**:`JsonStorageBackend` åœ¨æž„é€ æ—¶å¯¹é…�ç½®æ ¹ `resolve` ä¸€æ¬¡ï¼ŒåŽŸæ ·é‡‡çº³ JSONL å�Žç«¯å·²è®°å½•çš„ç�†ç”±ï¼šå�Žç» `process.cwd()` å�˜åŒ–ä¸�得把一个å�Žç«¯åŠˆåˆ°å¤šä¸ªæ ¹ä¸‹ã€‚SQLite å˜å‚¨å�Žç«¯å·²ç»� resolve 其路径。
+- 适用 pre-release ç«‹åœºï¼ˆå·²æŒ‰æ¤æ‰§è¡Œï¼‰ï¼šä¸�å�šè¿�移垫片。曾在 `/.storages` 下缓å˜è¿‡çš„部署è¦�么全部é‡�新派生(工作区从 header 索引é‡�æ–° bootstrapï¼›æŠ•å½±ç¼“å˜æƒ°æ€§é‡�折),è¦�么手动把两个 json 文件挪一次。
### 声明派生介质:��时�置而�拒�
@@ -31,7 +31,9 @@ Status: proposed
## Alternatives considered
-**ä¿�æŒ�按å�¯åŠ¨ç›®å½•çš„ `.storages`(现状)**——拒ç»�:会è¯�是全局的,所以æ¯�个从会è¯�派生的介质都与自己的真æº�劈å�‰ï¼›ç¼“å˜çš„动机场景(一次列出全部会è¯�)结构性丢行,工作区注册表索引ç�€ä»Žå�¦ä¸€ä¸ªå�¯åŠ¨ç›®å½•çœ‹ä¸�è§�的会è¯�。
+**ä¿�æŒ�按å�¯åŠ¨ç›®å½•çš„ `.storages`(改动å‰�现状)**——拒ç»�:会è¯�是全局的,所以æ¯�个从会è¯�派生的介质都与自己的真æº�劈å�‰ï¼›ç¼“å˜çš„动机场景(一次列出全部会è¯�)结构性丢行,工作区注册表索引ç�€ä»Žå�¦ä¸€ä¸ªå�¯åŠ¨ç›®å½•çœ‹ä¸�è§�的会è¯�。
+
+**launcher patch + `storageRoot` profile 键(本节åˆ�版æ��案形æ€�)**——未采:一行 yml `!!js` 表达å¼�å�³è¾¾å…¨å±€æ ¹ï¼Œä¸Žä¼šè¯�æ ¹çš„æ—¢æœ‰åˆ†å±‚å®Œå…¨ä¸€è‡´ï¼›launcher patch 多引入一个改写点,profile 键在有真实消费者å‰�是空å¸ï¼ˆæŒ‰è¡Œè¦†ç›–已有个人 config.yaml patch 层å�¯ç”¨ï¼‰ã€‚
**å�ªæŠŠæŠ•影缓å˜çš„ route æŒ‡åˆ°å…¨å±€æ ¹ï¼Œ`workspace.json` 留在 per-cwd**——拒ç»�ï¼šå·¥ä½œåŒºæ³¨å†Œè¡¨æœ‰ä¸€æ¨¡ä¸€æ ·çš„å…¨å±€ vs per-cwd é”™ä½�ï¼Œè€Œä¸”å¡‘é€ ç¼“å˜çš„用户决ç–就是刻æ„�把它放在 `workspace.json` æ—�边——一个 hub æ ¹è®©ä»‹è´¨å�Œå�€ã€�心智模型å�•一。
@@ -45,7 +47,7 @@ Status: proposed
## Acceptance criteria
-- 从任æ„�目录å�¯åЍ `dsh` 都读写å�Œä¸€ä»½ `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile é”® `storageRoot` å�¯è¦†ç›–;裸 Loader å�¯åЍ yml ä»�è�½åœ¨ç›¸å¯¹å�¯åЍ cwd çš„ `./.storages`,并在å�Žç«¯æž„é€ æ—¶ resolve 一次。
+- 从任æ„�目录å�¯åЍ `dsh` 都读写å�Œä¸€ä»½ `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`)——已由 overlay 表达å¼�满足,按行覆盖走个人 config.yaml patch 层;å�Žç«¯å¯¹ç›¸å¯¹æ ¹åœ¨æž„é€ æ—¶ resolve 一次(待å�šï¼‰ã€‚
- `session_projcache.json` 被截æ–ã€�版本 bump 或 schema 漂移时,组装干净å�¯åŠ¨ï¼šä¸€æ�¡è¦å‘Šå‘½å��被丢弃的介质,文件消失,缓å˜ç»�æ£å¸¸è¿�转é‡�建,冷列表列éš�会è¯�é‡�æ–° checkpoint é€�æ¥å›žå½’。
- å�Œæ ·çš„æ�Ÿå��å�‘生在 `workspace.json` 上ä»�大声拒ç»�å�¯åŠ¨ã€‚
- facility 测试覆盖:æ¯�个æ�Ÿå��ç±»æ�°å¥½é‡�置一次 `'reset'` 域;é�žæ�Ÿå��失败在 `'reset'` 域上ä¿�æŒ�大声;`'reject'` åŸŸä¼ æ’一切失败;`destroy` 在两个出厂å�Žç«¯ä¸Šéƒ½ç§»é™¤ä»‹è´¨ã€‚
diff --git a/.gitattributes b/.gitattributes
index a51c5e7b5e..2efafac8b8 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,3 +5,5 @@
# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the
# in-repo form stays LF; CRLF becomes checkout-time presentation only.
* text=auto eol=lf
+
+*.pdf binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6b2a5dd9d5..821948f873 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -108,11 +108,10 @@ jobs:
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / coverage
env:
- # Failover shrinks the worker bound: the hosted 32-core runner is
- # exclusive to one job, but the failover pool shares one 64-core VM
- # across six always-on runner instances, and the timing-sensitive
- # process suites have documented aggregate-contention failures.
- # 8 × 6 instances = 48 workers worst case on 64 cores.
+ # The hosted 16-core runner uses six coverage workers. The failover pool
+ # shares one 64-core VM across six always-on runner instances, so each
+ # instance may use eight while keeping the worst case at 8 × 6 = 48
+ # workers; process-bound suites remain isolated in forks.
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '6' }}
DSH_GATE_CONCURRENCY: '3'
steps:
diff --git a/README.i18n.yaml b/README.i18n.yaml
index 7584d4f293..b492ed9c37 100644
--- a/README.i18n.yaml
+++ b/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 README.md
-README.md: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3
-README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f
+README.md: b447c9634189353854e8be9d0bf597a8b0c7e371
+README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46
diff --git a/README.md b/README.md
index f9f7294b42..b447c96341 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,14 @@ DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Ha
It uses an architecture where **everything is a plugin**.
+## Internal testing notice
+
+感谢您愿�拨冗试用 DeepSeek Harness。当�版本�处于内部测试阶段,功能�待完善,体验难�有些粗糙。
+
+“如切如磋,如ç�¢å¦‚磨。â€� 产å“�çš„æˆ�长,离ä¸�开一次次真实的碰撞与å�¦è¯šçš„å��馈。您在真实使用ä¸å�‘现的问题,也å�¯èƒ½ä¿ƒä½¿æˆ‘们é‡�新审视,甚至推翻已有的设计。
+
+为了帮助我们更准确地还原您真实使用ä¸çš„é—®é¢˜ï¼Œå†…æµ‹ç‰ˆæœ¬é»˜è®¤ä¼šä¸Šä¼ æ‰€æœ‰ Session Log;如需关é—,å�¯ä»¥è®¾ç½®çŽ¯å¢ƒå�˜é‡� `DSH_TELEMETRY_DISABLED=1`。å�¦å¤–,如果您有任何å��馈与建议,请在ä¼�业微信群ä¸ç•™è¨€å‘Šè¯‰æˆ‘们。æ¯�一æ�¡å��馈,都会帮助我们把它打磨得更好。
+
## Install
Install `dsh` with one command:
@@ -22,20 +30,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~
### Web UI
-For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):
+For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:
```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
- link=$(readlink "$dsh_bin")
- case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
-The Web UI is served at `http://127.0.0.1:3080` by default.
+The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
### TUI
@@ -53,11 +55,22 @@ Run one task, print the final answer, and exit:
dsh -p "summarize this workspace"
```
+### Automation and SDKs
+
+From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:
+
+```sh
+pnpm run demo:acp
+```
+
+The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.
+
## Why DeepSeek Harness
-Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.
+Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.
- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.
+- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).
- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).
- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).
@@ -76,8 +89,10 @@ Start with the [development guide](docs/development.md) and read the [architectu
For agents, follow [AGENTS.md](AGENTS.md).
-DeepSeek Harness is currently pre-release.
+DeepSeek Harness is currently in internal testing.
## License
[BSD 3-Clause](LICENSE)
+
+Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/README.zh.md b/README.zh.md
index 88cbf8522d..f8bbbc36bc 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -6,6 +6,14 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开�
它采用了**一切皆�件**的架构。
+## 内测声明
+
+感谢您愿�拨冗试用 DeepSeek Harness。当�版本�处于内部测试阶段,功能�待完善,体验难�有些粗糙。
+
+“如切如磋,如ç�¢å¦‚磨。â€� 产å“�çš„æˆ�长,离ä¸�开一次次真实的碰撞与å�¦è¯šçš„å��馈。您在真实使用ä¸å�‘现的问题,也å�¯èƒ½ä¿ƒä½¿æˆ‘们é‡�新审视,甚至推翻已有的设计。
+
+为了帮助我们更准确地还原您真实使用ä¸çš„é—®é¢˜ï¼Œå†…æµ‹ç‰ˆæœ¬é»˜è®¤ä¼šä¸Šä¼ æ‰€æœ‰ Session Log;如需关é—,å�¯ä»¥è®¾ç½®çŽ¯å¢ƒå�˜é‡� `DSH_TELEMETRY_DISABLED=1`。å�¦å¤–,如果您有任何å��馈与建议,请在ä¼�业微信群ä¸ç•™è¨€å‘Šè¯‰æˆ‘们。æ¯�一æ�¡å��馈,都会帮助我们把它打磨得更好。
+
## 安装
使用一�命令安装 `dsh`:
@@ -22,20 +30,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m
### Web UI
-推è��在本地使用 Web UI。安装完æˆ�å�Žä»¥å�Šæ¯�次更新å�Žï¼Œè¯·å…ˆæž„建å‰�端,å†�å�¯åЍ Web UI。通过 `dsh` å�¯åŠ¨å™¨è§£æž�当å‰�è¿�è¡Œçš„æ£€å‡ºï¼Œè¿™æ ·æ— è®ºå½“å‰�是哪个 staging worktree,命令都æˆ�立(å�¯åŠ¨å™¨ä¼šç»�由稳定的 `current` 符å�·é“¾æŽ¥è§£æž�):
+推�在本地使用 Web UI。安装完��以��次更新�,请先构建当�生效的检出,��动 Web UI:
```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
- link=$(readlink "$dsh_bin")
- case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
-Web UI 默认通过 `http://127.0.0.1:3080` �供�务。
+完整构建会生æˆ�库与客户端 bundle,以å�Šå‰�端 dist。上述路径是安装器的默认ä½�ç½®ã€‚å¦‚æžœä½ è®¾ç½®è¿‡ `DSH_SOURCE` 或 `DSH_CURRENT`,或者å¤�用了已有检出,请把 `~/.dsh/source/current` æ�¢æˆ�该检出路径;详情è§� [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` æ��ä¾›æœ�务。
### TUI
@@ -53,11 +55,22 @@ dsh
dsh -p "summarize this workspace"
```
+### 自动化与 SDK
+
+在æº�ç �检出ä¸é€šè¿‡çŽ¯å¢ƒå�˜é‡�æˆ–æ ¹ç›®å½• `.env` 设置 `DEEPSEEK_API_KEY`,然å�Žå�¯åЍ ACP(Agent Client Protocol)自动化æœ�务器:
+
+```sh
+pnpm run demo:acp
+```
+
+[Python SDK](python/README.md) 驱动�附的 JSON-RPC �行时。[示例](examples/README.md)涵盖��行的 headless�ACP�JSON-RPC�Code Mode 和自指组�。
+
## 为什么选择 DeepSeek Harness
-内置功能涵盖文件读��编辑与�索�shell 执行���用 skill(技能)�任务跟踪�subagent 与工作���久化会�,以�上下文压缩(context compaction)。TUI 还包� Plan Mode。
+内置功能涵盖文件读å�–ã€�编辑与æ�œç´¢ã€�shell å’ŒæŒ�ä¹… PTY 执行ã€�å�¯å¤�用 skill(技能)ã€�任务跟踪ã€�ç›®æ ‡ã€�计划ã€�待办事项与å�Žå�°ä»»åŠ¡ã€�subagent 与工作æµ�ã€�沙箱与审批ã€�è®¾ç½®ä¸Žå‡æ�®ã€�å�¯æŒ�久化ã€�æ�¢å¤�ã€�fork 与查询的会è¯�ã€�LSP 与 Web 访问ã€�上下文压缩(context compaction),以å�Šé�¥æµ‹ã€‚æ¯�个组å�ˆå�ªé€‰ç”¨é€‚å�ˆå…¶ä½¿ç”¨æ–¹å¼�的能力å�集。TUI 与 Web UI å�‡åŒ…å�« Plan Mode。
- **一切皆æ�’件。** 模型ã€�工具ã€�ç–ç•¥ã€�å˜å‚¨ã€�上下文管ç�†å’Œç•Œé�¢å�‡å�¯ç»„å�ˆä¸º [Cordis æ�’ä»¶](docs/user/develop/basic/index.md)ï¼Œéƒ¨ç½²æ–¹æ— éœ€ fork agent loop(智能体循环)å�³å�¯æ‰©å±•或替æ�¢è¡Œä¸ºã€‚底层设计è§�[架构文档](docs/architecture.md)。
+- **è¿�行å�¯é‡�建。** 凡是模型å�¯è§�的内容,都会记录在æ�ƒå¨�会è¯�æµ�ä¸ï¼›æŒ�久化ã€�æ�¢å¤�ï¼�forkï¼�查询ã€�回放ã€�é�¥æµ‹å’Œ UI å�‡ä»Žå�Œä¸€ç»„事件派生。å�‚è§�[会è¯�日志架构](docs/architecture.md#session-log)。
- **Code Mode(需显��用)。** 它会�供 `run_code` 工具和生�的 TypeScript SDK,�有程�输出会�新进入模型上下文。�� [Code Mode](packages/core/tools/README.md#code-mode)。
- **自指 Cordis 工具需显å¼�å�¯ç”¨ã€‚** 这些工具å�¯è®© agent 检查自身的实时è¿�行时,并在è¿�è¡Œä¸æŒ‚载或å�¸è½½æ�’件。å�‚è§� [Cordis 工具](packages/cordis/tool-cordis/README.md)。
@@ -80,8 +93,10 @@ pnpm run test:coverage
�� agent:�循 [AGENTS.md](AGENTS.md)。
-DeepSeek Harness 目�处于预�布阶段。
+DeepSeek Harness 目�处于内测阶段。
## 许��
[BSD 3-Clause](LICENSE)
+
+第三方ä¾�èµ–å�Šå…¶è®¸å�¯è¯�在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) ä¸æŠ«éœ²ã€‚
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000000..075e1b34c4
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,165 @@
+
+
+# Third-Party Notices
+
+DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
+
+This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
+
+The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml).
+
+## Vendored source (`vendor/`)
+
+The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md).
+
+| Package | Upstream | License |
+| --- | --- | --- |
+| `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT |
+| `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT |
+| `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT |
+| `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT |
+| `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
+| `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
+| `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
+| `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
+| `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
+
+## Runtime npm dependencies
+
+External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
+
+| Package | License |
+| --- | --- |
+| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 |
+| [`@babel/code-frame`](https://github.com/babel/babel) | MIT |
+| [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT |
+| [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT |
+| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
+| [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi) | MIT |
+| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
+| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
+| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
+| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
+| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
+| [`clsx`](https://github.com/lukeed/clsx) | MIT |
+| [`commander`](https://github.com/tj/commander.js) | MIT |
+| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
+| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause |
+| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
+| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT |
+| [`immer`](https://github.com/immerjs/immer) | MIT |
+| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
+| [`jsonc-parser`](https://github.com/microsoft/node-jsonc-parser) | MIT |
+| [`koffi`](https://github.com/Koromix/koffi) | MIT |
+| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
+| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
+| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
+| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
+| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
+| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
+| [`react`](https://github.com/facebook/react) | MIT |
+| [`react-dom`](https://github.com/facebook/react) | MIT |
+| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT |
+| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT |
+| [`saxes`](https://github.com/lddubeau/saxes) | ISC |
+| [`shiki`](https://github.com/shikijs/shiki) | MIT |
+| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
+| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
+| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
+| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 |
+| [`use-sync-external-store`](https://github.com/facebook/react) | MIT |
+| [`yaml`](https://github.com/eemeli/yaml) | ISC |
+| [`zod`](https://github.com/colinhacks/zod) | MIT |
+| [`zustand`](https://github.com/pmndrs/zustand) | MIT |
+
+pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
+
+- `@earendil-works/pi-tui@0.80.7` — [`patches/@earendil-works__pi-tui@0.80.7.patch`](patches/@earendil-works__pi-tui@0.80.7.patch)
+- `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch)
+
+## 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.
+
+| Package | License |
+| --- | --- |
+| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
+| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
+| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
+| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT |
+| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
+| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
+| [`@types/babel__code-frame`](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/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT |
+| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
+| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
+| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
+| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
+| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
+| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
+| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
+| [`debug`](https://github.com/debug-js/debug) | MIT |
+| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
+| [`eslint`](https://github.com/eslint/eslint) | MIT |
+| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
+| [`execa`](https://github.com/sindresorhus/execa) | MIT |
+| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
+| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
+| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
+| [`knip`](https://github.com/webpro-nl/knip) | ISC |
+| [`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 |
+| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
+| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
+| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
+| [`publint`](https://github.com/publint/publint) | MIT |
+| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
+| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
+| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
+| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
+| [`vite`](https://github.com/vitejs/vite) | MIT |
+| [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT |
+| [`vitepress`](https://github.com/vuejs/vitepress) | MIT |
+| [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT |
+| [`vitest`](https://github.com/vitest-dev/vitest) | MIT |
+
+`eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.
+
+## Python SDK dependencies (`python/`)
+
+Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool.
+
+| Package | License | Role |
+| --- | --- | --- |
+| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend |
+| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` |
+| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
+| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
+
+## Fetched at build time
+
+| Package | License | Role |
+| --- | --- | --- |
+| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable |
+
+## First-party sibling releases
+
+`node-addon-landlock-run` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index c3bf91e88f..e7a4161df1 100644
--- a/apps/cli/README.i18n.yaml
+++ b/apps/cli/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: e4b34c11d5deb722caed199d6350f7931092a636
-README.zh.md: 5701bc8b6d99f00e68db572a58a0b6d520d67f08
+README.md: aee08225febe34bb10ee1001c798db9d7daa8905
+README.zh.md: 8f38fd342a0433ca5f7645f896b81aa5392033ae
diff --git a/apps/cli/README.md b/apps/cli/README.md
index e4b34c11d5..aee08225fe 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -3,7 +3,7 @@
English | [䏿–‡](README.zh.md)
-Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
+Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface:
@@ -17,13 +17,24 @@ The TUI surface:
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection.
+`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
-The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
+The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
+The Web/headless composition also registers only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The TUI composition does not mount Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
+
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
+[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry.
+
+From a source checkout, start this minimal Web profile with:
+
+```sh
+pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
+```
+
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
## Install (developer machine)
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 5701bc8b6d..8f38fd342a 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -3,7 +3,7 @@
[English](README.md) | 䏿–‡
-Argv å�ªä¼šé€šè¿‡ [Commander](https://github.com/tj/commander.js) 适é…�器([`src/args.ts`](src/args.ts))解æž�一次:å�Œä¸€ä¸ªç¨‹åº�的默认形å¼�ï¼ˆæ— å�命令)是 TUIï¼�æ— å¤´ç•Œé�¢ï¼ˆ`--config`ã€�`-p`/`--prompt`ã€�`--resume`),`meta` å�命令是以本 checkout 为 workspace çš„å�Œä¸€ä¸ª TUI,`upgrade` å�å‘½ä»¤æ˜¯æ— é€‰é¡¹çš„å¼•å¯¼ä¼šè¯�å…¥å�£ï¼Œ`web` å�命令则是æµ�览器 UI。`src/bin.ts` 按解æž�å�Žçš„ mode 分支,仅动æ€�导入该 mode 的模å�—。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打å�°æ¤åº”用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出ç � 1),而ä¸�会被错路由。凡与默认界é�¢ä¸�共享任何选项的å�命令(`upgrade`ã€�`web`ã€�`meta`)都会拒ç»�泄æ¼�è¿›æ�¥çš„ `--config`/`-p`/`--resume`,而ä¸�会照常è¿�行并丢弃它。`dsh web` çš„ `--host`/`--port` 是未验è¯�的直通覆盖:`dsh-host-webserver` schema æ˜¯é»˜è®¤å€¼ï¼ˆæ ‡å¿—ç¼ºå¤±æ—¶ä½¿ç”¨å·²äº¤ä»˜çš„ Web 覆盖层值)和有效性的唯一真æº�,并在å�¯åŠ¨æ—¶æ‹’ç»�错误值。`--trusted-host` 为 /api æµ�è§ˆå™¨ä¿¡ä»»æ …æ �è¿½åŠ å…·å��æ�ƒå¨�;全接å�£ç»‘定还会自行推导本机的 LAN IP å—é�¢é‡�([`src/app-cli-entry.ts`](src/app-cli-entry.ts)ï¼‰ï¼Œå› æ¤æ‰“å�°å‡ºçš„ LAN URL æ— éœ€ä»»ä½•æ ‡å¿—å�³å�¯ä½¿ç”¨ã€‚
+Argv å�ªä¼šé€šè¿‡ [Commander](https://github.com/tj/commander.js) 适é…�器([`src/args.ts`](src/args.ts))解æž�一次:å�Œä¸€ä¸ªç¨‹åº�的默认形å¼�ï¼ˆæ— å�命令)是 TUIï¼�æ— å¤´ç•Œé�¢ï¼ˆ`--config`ã€�`-p`/`--prompt`ã€�`--resume`ã€�`--dump-config`ã€�`--dump-default-config`),`meta` å�命令是以本 checkout 为 workspace çš„å�Œä¸€ä¸ª TUI,`upgrade` å�å‘½ä»¤æ˜¯æ— é€‰é¡¹çš„å¼•å¯¼ä¼šè¯�å…¥å�£ï¼Œ`web` å�命令则是æµ�览器 UI。`src/bin.ts` 按解æž�å�Žçš„ mode 分支,仅动æ€�导入该 mode 的模å�—。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打å�°æ¤åº”用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出ç � 1),而ä¸�会被错路由。凡与默认界é�¢ä¸�共享任何选项的å�命令(`upgrade`ã€�`web`ã€�`meta`)都会拒ç»�泄æ¼�è¿›æ�¥çš„ `--config`/`-p`/`--resume`/dump æ ‡å¿—ï¼Œè€Œä¸�会照常è¿�行并丢弃它。`dsh web` çš„ `--host`/`--port` 是未验è¯�的直通覆盖:`dsh-host-webserver` schema æ˜¯é»˜è®¤å€¼ï¼ˆæ ‡å¿—ç¼ºå¤±æ—¶ä½¿ç”¨å·²äº¤ä»˜çš„ Web 覆盖层值)和有效性的唯一真æº�,并在å�¯åŠ¨æ—¶æ‹’ç»�错误值。`--trusted-host` 为 /api æµ�è§ˆå™¨ä¿¡ä»»æ …æ �è¿½åŠ å…·å��æ�ƒå¨�;全接å�£ç»‘定还会自行推导本机的 LAN IP å—é�¢é‡�([`src/app-cli-entry.ts`](src/app-cli-entry.ts)ï¼‰ï¼Œå› æ¤æ‰“å�°å‡ºçš„ LAN URL æ— éœ€ä»»ä½•æ ‡å¿—å�³å�¯ä½¿ç”¨ã€‚
TUI 界�:
@@ -17,13 +17,24 @@ TUI 界�:
`dsh upgrade` 是默认 TUI 界é�¢ä¹‹ä¸Šçš„引导å¼�全新会è¯�å…¥å�£ï¼šå®ƒåœ¨è°ƒç”¨ç›®å½•ä¸åˆ›å»ºä¸€ä¸ªå…¨æ–°ä¼šè¯�,并以内置 `dsh-upgrade` skill æ’ç§�其首轮,效果ç‰å�ŒäºŽç”¨æˆ·æ‰‹åЍ键入 `/skill:`。å�¯åЍ噍将 skill å��ç§°æ��供到å�¯åŠ¨ä¸Šä¸‹æ–‡ï¼ˆ[`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在è�Šå¤©å°±ç»ªå�Žè‡ªåŠ¨è°ƒç”¨å®ƒã€‚ä¸¤è€…éƒ½ä¸�接å�—任何选项——`--config`ã€�`-p`ã€�`--resume` 都会明确报错——且仅在首次å�¯åŠ¨æ—¶æ’ç§�ï¼Œå› æ¤ä¹‹å�Ž `dsh --resume ` æ�¢å¤�该会è¯�时是普通 TUI 会è¯�,ä¸�会é‡�å¤�注入。
+`dsh --dump-config` å’Œ `dsh web --dump-config` 把å�ˆæˆ�å�Žçš„é…�ç½®æ ‘â€”â€”å·²äº¤ä»˜çš„åŸºç¡€é…�ç½®ã€�界é�¢è¦†ç›–层,以å�Š `--config` 或个人覆盖层,æ�°å¥½æ˜¯è¯¥ç•Œé�¢å�¯åŠ¨æ—¶ç»„è£…çš„é‚£äº›å±‚â€”â€”ä»¥ YAML 打å�°åˆ° stdout å�Žé€€å‡ºï¼Œä¸�å�¯åŠ¨ä»»ä½•ä¸œè¥¿ï¼›`--dump-default-config` æ¢æ¥äºŽç•Œé�¢è¦†ç›–å±‚ï¼Œå› æ¤å¯¹ä¸¤ä»½è¾“出å�š diff 就能精确看出用户层改了什么。æ¯�段连ç»çš„行之å‰�都有一æ�¡ `# ==` æ³¨é‡Šï¼Œæ ‡æ˜Žè¯¥æ®µæ�¥è‡ªå“ªä¸ªæ–‡ä»¶ä»¥å�Šè¢«å“ªäº›å±‚修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`ï¼‰ï¼Œå› æ¤è¾“出既展示æ�¥æº�,å�ˆä»�是一份å�¯åŠ è½½çš„æ–‡æ¡£ã€‚å�ˆæˆ�通过 include 自己的补ä¸�算法和 YAML 方言(`@cordisjs/plugin-include` çš„ `applyEntryPatches`/`entryListSchema`)完æˆ�ï¼Œå› æ¤ dump ä¸�å�¯èƒ½ä¸Žå®žé™…å�¯åŠ¨æ¼‚ç§»ï¼›`!!js` 表达å¼�åŽŸæ ·æ‰“å�°ã€�ä¸�æ±‚å€¼ï¼Œç›®æ ‡è¡Œä¸�å˜åœ¨çš„è¡¥ä¸�会连å�Œå…¶æ‰€åœ¨å±‚报到 stderr,与 Loader å�¯åŠ¨æ—¶çš„è¦å‘Šä¸€è‡´ã€‚ç”±å�¯åЍ噍æŒ�有的å�¯åŠ¨ä¸Šä¸‹æ–‡å€¼ï¼ˆä¼šè¯�身份ã€�CLI æ ‡å¿—è¡¥ä¸�)是æ¯�次调用的事实,ä½�于é…�ç½®æ ‘ä¹‹å¤–ï¼Œä¸�会出现。dump æ ‡å¿—ä¼šæ‹’ç»�仅用于å�¯åŠ¨çš„æ ‡å¿—ï¼ˆ`-p`ã€�`--resume`ã€�`--config-replace`)而ä¸�是é�™é»˜å¿½ç•¥å®ƒä»¬ï¼Œ`--dump-default-config` ä¸�接å�— `--config`。
-Web å’Œæ— å¤´ç•Œé�¢å�¯åЍ `base.cordis.yml` 与 `web.cordis.yml`,éš�å�Žåº”用 `$DSH_HOME/config.yaml`;显å¼�çš„ `--config ` 会替代该个人覆盖。除æ¤ä¹‹å¤–,两者共享å�Œä¸€å¥—组å�ˆï¼šä¸¤è€…都将调用目录视为默认项目和 Workspace æ ¹ç›®å½•ï¼Œé™¤é�žé€šè¿‡ `--workspace-root ` 覆盖,å�¦åˆ™ä¼šåœ¨è¯¥æ ¹ç›®å½•下创建具å�� Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` æŒ‡ä»¤åŠ è½½åˆ°æ¯�个 agent-loop 请求å‰�ç¼€ä¸ï¼Œæ¸²æŸ“预算为 65,536 å—节,并选用首æ�¡æ¶ˆæ�¯æ¨¡åž‹æ ‡é¢˜ï¼Œä¸”挂载一个å�¯ä¸¢å¼ƒçš„å†…å˜ SQLite 内容索引æœ�务。该æœ�务在å�¯åŠ¨æ—¶å¤„äºŽ ACTIVE 状æ€�,但其 `node:sqlite` 模å�—与数æ�®åº“å�¥æŸ„分别è¦�到首次内容æ�œç´¢æ‰�ä¼šå¯¼å…¥å’Œæ‰“å¼€ã€‚è¿™æ ·å�¯ä½¿ Node 22 在尚未使用æ�œç´¢æ—¶çš„å�¯åŠ¨è¾“å‡ºä¸�出现 SQLite 实验性è¦å‘Šï¼›é¦–次实际æ�œç´¢ä»�å�¯èƒ½å�‘出è¿�行时è¦å‘Šã€‚æ¯�个æœ�务实例独å� 自己的数æ�®åº“ï¼Œå› æ¤å¹¶è¡Œè°ƒç”¨æ—¢ä¸�会共享ä¸�å�—支æŒ�çš„ SQLite 状æ€�,也ä¸�会留下派生索引文件,首次æ�œç´¢è¿˜ä¼šæƒ°æ€§å¯¹è´¦å®žæ—¶æ—¥å¿—与æŒ�ä¹…åŒ–æ—¥å¿—ã€‚æ— å¤´ç•Œé�¢å”¯ä¸€çš„差异是监å�¬æ“�作系统分é…�的端å�£ï¼ˆå¹¶è¡Œ `dsh -p` è¿�行ç»�ä¸�冲çª�ï¼›stderr 打å�°çš„ URL 会在æµ�è§ˆå™¨ä¸æ‰“开实时会è¯�)。两者都需è¦�先构建å‰�端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
+Web å’Œæ— å¤´ç•Œé�¢å�¯åЍ `base.cordis.yml` 与 `web.cordis.yml`,éš�å�Žåº”用 `$DSH_HOME/config.yaml`;显å¼�çš„ `--config ` 会替代该个人覆盖。除æ¤ä¹‹å¤–,两者共享å�Œä¸€å¥—组å�ˆï¼šä¸¤è€…都将调用目录视为默认项目和 Workspace æ ¹ç›®å½•ï¼Œé™¤é�žé€šè¿‡ `--workspace-root ` 覆盖,å�¦åˆ™ä¼šåœ¨è¯¥æ ¹ç›®å½•下创建具å�� Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` æŒ‡ä»¤åŠ è½½åˆ°æ¯�个 agent-loop 请求å‰�ç¼€ä¸ï¼Œæ¸²æŸ“预算为 65,536 å—节,选用首æ�¡æ¶ˆæ�¯æ¨¡åž‹æ ‡é¢˜ï¼Œé‡‡ç”¨ä¸Ž TUI 相å�Œçš„æœ‰ç•Œæš‚时性模型请求é‡�试ç–略,并挂载一个å�¯ä¸¢å¼ƒçš„å†…å˜ SQLite 内容索引æœ�务。该æœ�务在å�¯åŠ¨æ—¶å¤„äºŽ ACTIVE 状æ€�,但其 `node:sqlite` 模å�—与数æ�®åº“å�¥æŸ„分别è¦�到首次内容æ�œç´¢æ‰�ä¼šå¯¼å…¥å’Œæ‰“å¼€ã€‚è¿™æ ·å�¯ä½¿ Node 22 在尚未使用æ�œç´¢æ—¶çš„å�¯åŠ¨è¾“å‡ºä¸�出现 SQLite 实验性è¦å‘Šï¼›é¦–次实际æ�œç´¢ä»�å�¯èƒ½å�‘出è¿�行时è¦å‘Šã€‚æ¯�个æœ�务实例独å� 自己的数æ�®åº“ï¼Œå› æ¤å¹¶è¡Œè°ƒç”¨æ—¢ä¸�会共享ä¸�å�—支æŒ�çš„ SQLite 状æ€�,也ä¸�会留下派生索引文件,首次æ�œç´¢è¿˜ä¼šæƒ°æ€§å¯¹è´¦å®žæ—¶æ—¥å¿—与æŒ�ä¹…åŒ–æ—¥å¿—ã€‚æ— å¤´ç•Œé�¢å”¯ä¸€çš„差异是监å�¬æ“�作系统分é…�的端å�£ï¼ˆå¹¶è¡Œ `dsh -p` è¿�行ç»�ä¸�冲çª�ï¼›stderr 打å�°çš„ URL 会在æµ�è§ˆå™¨ä¸æ‰“开实时会è¯�)。两者都需è¦�先构建å‰�端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
已交付的 TUI å’Œ Web 组å�ˆä¼šæ³¨å†ŒåŽŸç”Ÿ DeepSeek 适é…�器,以å�Š pi-ai çš„ OpenAI å’Œ Anthropic æ��供方é…�ç½®ã€‚å‡æ�®å’Œç«¯ç‚¹è¦†ç›–æ�¥è‡ªå�¯åŠ¨åˆ†å±‚çŽ¯å¢ƒä¸çš„æ��ä¾›æ–¹æ ‡å‡†å�˜é‡�对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`ã€�`OPENAI_API_KEY` / `OPENAI_BASE_URL` å’Œ `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
+Webï¼�æ— å¤´ç»„å�ˆè¿˜å�ªä¼šæ³¨å†Œ `web_search`。æ�œç´¢ä½¿ç”¨ DeepSeek çš„ Anthropic 兼容 Messages 端点,æ¯�次调用都会解æž�å�Œä¸€ä¸ª `DEEPSEEK_API_KEY` 凿�®å¼•用,并接å�—独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;æ¯�次æ�œç´¢éƒ½æ˜¯ä¸€æ¬¡è¾…助模型请求,会产生独立的延迟与 token æˆ�本。`web_fetch` ä»�处于ç¦�用状æ€�,组å�ˆä¹ŸæœªæŒ‚载默认抓å�–æ��供方;需è¦�ä»»æ„�页é�¢æŠ“å�–能力的部署必须通过覆盖层选择å�¯ç”¨ã€‚TUI 组å�ˆé»˜è®¤ä¸�挂载 Web 工具。部署决ç–å�Šå…¶å®‰å…¨è¾¹ç•Œè§�[默认 Web æ�œç´¢ Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
+
`DSH_TOOLS_MODE` 为整个 Webï¼�æ— å¤´è¿›ç¨‹é€‰æ‹©å·¥å…·å‘ˆçŽ°æ¨¡å¼�:å�¯é€‰å€¼ä¸º `native`(未设置时的 schema 默认值)ã€�`code`(仅å�« `run_code` çš„ Code Mode å��议接å�£ï¼‰æˆ– `both`;任何其他值都会ç»�ç”± `dsh-tools` é…�ç½® schema 在å�¯åŠ¨æ—¶æ˜Žç¡®æŠ¥é”™ã€‚å®ƒæ˜¯ä¸€ä¸ªä¸´æ—¶ seam:Loader 组å�ˆæ˜¯é�™æ€�çš„ï¼Œå› æ¤è¯¥è®¾ç½®ä½œç”¨äºŽæ•´ä¸ªè¿›ç¨‹ï¼›å¾… Web UI è´Ÿè´£é€�会è¯�工具模å¼�选择å�Žä¾¿ä¼šç§»é™¤ã€‚TUI 界é�¢ä¼šå¿½ç•¥è¯¥å�˜é‡�(其é…�ç½®æ ‘å›ºå®šäº†è‡ªèº«æ¨¡å¼�)。
+[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个å�¯é€‰å�¯ç”¨çš„ `dsh web --config` 覆盖层:它ä¿�留已交付的 Web 宿主ã€�æµ�览器ã€�Workspaceã€�æŒ�久化与æ�ƒé™�组å�ˆï¼Œå�Œæ—¶å°†é»˜è®¤çš„原生模型界é�¢ç²¾ç®€ä¸ºä»¥æ‰€æœ‰è€…为作用域的æŒ�ä¹… `bash` 以å�Š `str_replace_editor`。PTY å�Žç«¯å’Œç¼–辑器分别消费现有的 Web 沙箱与文件系统æ��供方。æŒ�ä¹… shell 处于打开状æ€�æ—¶ï¼Œä¼šé˜»æ¢æ‰€å±žä¼šè¯�更改æ�ƒé™�模å¼�ï¼›å› æ¤ï¼Œåœ¨è¾ƒå®½æ�ƒé™�下创建的 shell æ— æ³•åœ¨é™�æ�ƒå�Žç»§ç»å˜æ´»ã€‚`DSH_TOOLS_MODE` ä»�控制由æ¤å¾—到的å�Œå·¥å…·æ³¨å†Œè¡¨é‡‡ç”¨åŽŸç”Ÿï¼�Code Mode 呈现。
+
+在æº�ç � checkout ä¸ï¼Œç”¨ä»¥ä¸‹å‘½ä»¤å�¯åŠ¨è¿™ä¸ªç²¾ç®€ Web profile:
+
+```sh
+pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
+```
+
æ¯�个 `dsh` 界é�¢â€”—TUIã€�Web ä¸Žæ— å¤´â€”â€”éƒ½é»˜è®¤ä¸ŠæŠ¥ä¼šè¯�é�¥æµ‹ï¼ˆè¯¥è¡Œä½�于共享的 `base.cordis.yml`):æ¯�æ�¡ä¼šè¯�日志事件以 OTLP/HTTP 日志记录的形å¼�ã€�按 10 秒批处ç�†èŠ‚å¥�æµ�å�‘ `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` å�¯å°† exporter 指å�‘å…¶ä»– collector;将 `DSH_TELEMETRY_DISABLED` 设为**ä»»æ„�é�žç©ºå€¼**——包括 `0` 或 `false`â€”â€”éƒ½ä¼šåœ¨è¯¥è¡ŒåŠ è½½å‰�将其关å�œï¼ˆéš�ç§�开关å�–「å®�å�¯è¯¯å…³ã€�ä¸�å�¯è¯¯å¼€ã€�)。该组å�ˆå½“å‰�未挂载任何脱æ•�规则:导出记录å�³åŽŸå§‹æ�•获副本,包å�«æ¶ˆæ�¯æ£æ–‡ã€�工具å�‚数与结果ã€�以å�Šä¼šè¯�工作目录路径。部署å�£å¾„è§� [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
## 安装(开�机)
diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml
index 5e0940d241..4bde1e4b6a 100644
--- a/apps/cli/config/base.cordis.yml
+++ b/apps/cli/config/base.cordis.yml
@@ -84,8 +84,7 @@
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
- root: !!js >-
- (() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
+ root: !!js dshHomePath('sessions')
# TUI consumes this shared session capability. Its launcher supplies a unique
# process-local path; other surfaces repoint or disable the row in their
@@ -105,7 +104,9 @@
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
-# a row). The exporter/processor values bound the shutdown drain to ~1s
+# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
+# random UUID; delete the file to reset the identity) as the Resource's
+# user.id. The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml
new file mode 100644
index 0000000000..347a5bfbe6
--- /dev/null
+++ b/apps/cli/config/core-web.cordis.yml
@@ -0,0 +1,72 @@
+# Opt-in two-tool profile over the shipped Web composition. The default native
+# model surface is exactly persistent `bash` plus `str_replace_editor`; the
+# Web host, browser shell, workspace, persistence, and permission stack remain.
+
+# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
+# always-registered exit_plan_mode tool even while the session is not planning.
+- id: tool-bash
+ disabled: true
+
+- id: tool-tasks
+ disabled: true
+
+- id: tool-fs
+ disabled: true
+
+- id: tool-fs-search
+ disabled: true
+
+- id: tool-web
+ disabled: true
+
+- id: tool-skill
+ disabled: true
+
+- id: plan-mode
+ disabled: true
+
+- id: tool-subagent
+ disabled: true
+
+- id: tool-subagent-fork
+ disabled: true
+
+- id: tool-workflow
+ disabled: true
+
+- id: tool-todo
+ disabled: true
+
+# The matching browser controls must not offer host tools that this profile
+# omits. ui-question's host half owns the ask_user_question registration.
+- id: ui-plan
+ disabled: true
+
+- id: ui-question
+ disabled: true
+
+- insert:
+ - id: pty
+ name: '@deepseek-ai/dsh-pty'
+
+ # This backend consumes the existing Web sandbox and permission policy.
+ # It loads only on Linux/macOS; Windows and other platforms fail at boot.
+ # Its 300s send wait matches the persistent Bash command timeout instead of
+ # pty-local's 30s default. An open persistent shell fences permission-mode
+ # changes until it closes.
+ - id: pty-local
+ name: '@deepseek-ai/dsh-pty-local'
+ config:
+ timeoutMs: 300000
+
+ - id: persistent-bash
+ name: '@deepseek-ai/dsh-tool-bash-persistent'
+ config:
+ timeoutMs: 300000
+
+ # The editor consumes the Web fs-sandbox provider and therefore retains
+ # the selected session permission mode.
+ - id: str-replace-editor
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
+ config:
+ maxOutputChars: 16000
diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml
index a67d95ff6b..159b2e270c 100644
--- a/apps/cli/config/web.cordis.yml
+++ b/apps/cli/config/web.cordis.yml
@@ -80,6 +80,28 @@
- id: fs-local
disabled: true
+# The Web/headless product enables only the stable web_search model surface.
+# DeepSeek search resolves the same DEEPSEEK_API_KEY credential the Models page
+# manages for chat, at each search; its Messages endpoint is separate from the
+# chat-completions endpoint. Fetch remains disabled and no default fetch
+# provider is mounted.
+- insert:
+ - id: web
+ name: '@deepseek-ai/dsh-web'
+ config:
+ searchProvider: deepseek-official
+
+ - id: web-search-deepseek
+ name: '@deepseek-ai/dsh-web-search-deepseek'
+ config:
+ apiKeyEnv: DEEPSEEK_API_KEY
+ baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
+
+ - id: tool-web
+ name: '@deepseek-ai/dsh-tool-web'
+ config:
+ fetch: false
+
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into
@@ -97,7 +119,7 @@
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
- root: './.storages'
+ root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 787682ce04..997cea8e5f 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -68,9 +68,12 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
+ "@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
+ "@deepseek-ai/dsh-pty": "workspace:^",
+ "@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
@@ -103,20 +106,25 @@
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
+ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
+ "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
+ "@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
+ "@deepseek-ai/dsh-web": "workspace:^",
+ "@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts
index a1f0fff4c4..ac74b9468d 100644
--- a/apps/cli/src/args.ts
+++ b/apps/cli/src/args.ts
@@ -23,6 +23,22 @@ interface TuiInvocation {
resume?: string
}
+/**
+ * Print the composed config tree and exit, without booting: `--dump-config`
+ * composes the shipped base, the surface overlay, and the `--config` or
+ * personal overlay — exactly the layers that surface would boot;
+ * `--dump-default-config` stops at the surface overlay (the shipped tree, no
+ * user layer).
+ */
+interface DumpConfigInvocation {
+ mode: 'dump-config'
+ surface: 'tui' | 'web'
+ /** Omit the `--config`/personal layer and print only the shipped composition. */
+ defaultOnly: boolean
+ /** The `--config` overlay to compose instead of the personal one. */
+ config?: string
+}
+
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
@@ -69,6 +85,7 @@ interface WebInvocation {
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation =
| TuiInvocation
+ | DumpConfigInvocation
| HeadlessInvocation
| MetaInvocation
| SkillSessionInvocation
@@ -82,6 +99,34 @@ interface WebOptions {
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
+ dumpConfig?: boolean
+ dumpDefaultConfig?: boolean
+}
+
+/**
+ * Resolve the two dump flags for one surface, or return `undefined` when
+ * neither was passed. Both flags together are contradictory (one includes the
+ * user layer, the other excludes it) and fail loud through `error`.
+ */
+function resolveDump(
+ surface: 'tui' | 'web',
+ options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
+ error: (message: string) => never,
+): DumpConfigInvocation | undefined {
+ if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
+ if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
+ error('error: --dump-config and --dump-default-config are mutually exclusive')
+ }
+ const defaultOnly = options.dumpDefaultConfig === true
+ if (defaultOnly && options.config !== undefined) {
+ error('error: --dump-default-config prints the shipped tree and takes no --config')
+ }
+ return {
+ mode: 'dump-config',
+ surface,
+ defaultOnly,
+ ...options.config !== undefined && { config: options.config },
+ }
}
/**
@@ -135,7 +180,26 @@ Examples:
.option('--resume ', 'continue a past session by id')
.option('--config ', 'apply this overlay of loader patches instead of the personal one')
.option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
- .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => {
+ .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
+ .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
+ .action((options: {
+ config?: string
+ configReplace?: string
+ prompt?: string
+ resume?: string
+ dumpConfig?: boolean
+ dumpDefaultConfig?: boolean
+ }) => {
+ const dump = resolveDump('tui', options, message => program.error(message))
+ if (dump !== undefined) {
+ // The dump prints composition; a boot-only flag alongside it would be
+ // silently ignored, so reject the mix loud.
+ if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) {
+ program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace')
+ }
+ resolved = dump
+ return
+ }
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
@@ -168,10 +232,18 @@ Examples:
// a leaked config/prompt/resume option is a mistyped invocation that must fail
// loud rather than silently run and drop the input.
const rejectParentOptions = (command: string): void => {
- const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>()
+ const parent = program.opts<{
+ config?: string
+ configReplace?: string
+ prompt?: string
+ resume?: string
+ dumpConfig?: boolean
+ dumpDefaultConfig?: boolean
+ }>()
if (parent.config !== undefined || parent.configReplace !== undefined
- || parent.prompt !== undefined || parent.resume !== undefined) {
- program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`)
+ || parent.prompt !== undefined || parent.resume !== undefined
+ || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
+ program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`)
}
}
@@ -198,8 +270,15 @@ Examples:
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--workspace-root ', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
+ .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
+ .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
.action((options: WebOptions) => {
rejectParentOptions('web')
+ const dump = resolveDump('web', options, message => program.error(message))
+ if (dump !== undefined) {
+ resolved = dump
+ return
+ }
resolved = resolveWeb(options)
})
diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts
index 6f7ae77c76..a1e46ccb1e 100644
--- a/apps/cli/src/bin.ts
+++ b/apps/cli/src/bin.ts
@@ -43,14 +43,19 @@ switch (invocation.mode) {
await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
break
}
+ case 'dump-config': {
+ const { runDumpConfig } = await import('./dump-config.ts')
+ runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config)
+ break
+ }
case 'meta': {
- const { runMeta } = await import('./tui.ts')
- await runMeta()
+ const { runTui, SOURCE_ROOT } = await import('./tui.ts')
+ await runTui(undefined, undefined, SOURCE_ROOT)
break
}
case 'upgrade': {
- const { runSkillSession } = await import('./tui.ts')
- await runSkillSession(`dsh-${invocation.mode}`)
+ const { runTui } = await import('./tui.ts')
+ await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
break
}
default:
diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts
new file mode 100644
index 0000000000..39a87c2dc8
--- /dev/null
+++ b/apps/cli/src/dump-config.ts
@@ -0,0 +1,61 @@
+/**
+ * `dsh --dump-config` / `dsh web --dump-config` — print the composed config
+ * tree without booting: the shipped base, the surface overlay, and (unless
+ * `--dump-default-config`) the `--config` or personal overlay, composed
+ * through the include's own patch algorithm so the printed tree is exactly
+ * what that surface would mount. `!!js` expressions print verbatim,
+ * unevaluated — the dump shows composition, not one process's environment.
+ * Launcher-provided boot-context values (session identity, CLI-flag patches)
+ * are per-invocation facts outside the config tree and do not appear.
+ * @module @deepseek-ai/dsh/dump-config
+ */
+
+import { basename, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import {
+ loadOverlayPatches,
+ loadPersonalPatches,
+ PERSONAL_CONFIG_FILENAME,
+ renderConfigDump,
+ type ConfigDumpLayer,
+} from '@deepseek-ai/dsh-app-boot'
+import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+
+const NAME = 'dsh'
+
+const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
+const SURFACE_OVERLAYS = {
+ tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)),
+ web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
+} as const
+
+/* v8 ignore start -- composition over the unit-tested renderConfigDump; the
+ built-bin e2e drives this path end to end */
+/**
+ * Print one surface's composed config tree to stdout, with a comment
+ * separator naming the file each section of rows comes from (and the layers
+ * that patched it).
+ * @param surface - which surface overlay to compose over the shared base.
+ * @param defaultOnly - stop at the surface overlay (no `--config`/personal layer).
+ * @param config - the `--config` overlay path composed instead of the personal
+ * one, or `undefined` to use `$DSH_HOME/config.yaml`.
+ */
+export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void {
+ const overlay = SURFACE_OVERLAYS[surface]
+ const layers: ConfigDumpLayer[] = [
+ { label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) },
+ ]
+ if (!defaultOnly) {
+ if (config === undefined) {
+ const personal = loadPersonalPatches(NAME)
+ // The personal file may be absent; the shipped layers still print.
+ if (personal !== undefined) {
+ layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal })
+ }
+ } else {
+ layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
+ }
+ }
+ process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers))
+}
+/* v8 ignore stop */
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts
index dee865a0e1..7809e9ec1c 100644
--- a/apps/cli/src/tui.ts
+++ b/apps/cli/src/tui.ts
@@ -8,12 +8,10 @@
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
- * `dsh meta`
- * ({@link runMeta}) is the one exception — it makes this harness checkout the
- * workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
- * session whose first turn auto-invokes a bundled skill. After boot, the
- * agent's system prompt is told the path to this harness checkout so it can
- * find its own source.
+ * `dsh meta` is the one exception — it makes this harness checkout the
+ * workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a
+ * bundled skill. After boot, the agent's system prompt is told the path to this
+ * harness checkout so it can find its own source.
* @module @deepseek-ai/dsh/tui
*/
@@ -61,30 +59,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
-const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
+/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
+export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the CLI PTY smoke drives this path end to end, personal overlay included */
-/**
- * Run the interactive TUI with this harness checkout as the workspace
- * (`dsh meta`), whatever directory it was launched from.
- */
-export async function runMeta(): Promise {
- return runTui(undefined, undefined, SOURCE_ROOT)
-}
-
-/**
- * Run the interactive TUI as a guided fresh session whose first turn invokes a
- * bundled skill (`dsh upgrade` → `dsh-upgrade`).
- * Always mints a fresh session in the invoking directory; the skill is seeded
- * only on this first launch, so a later `--resume` of the session is an ordinary
- * TUI session with no re-injection.
- * @param skill - the bundled skill name to auto-invoke as the first turn.
- */
-export async function runSkillSession(skill: string): Promise {
- return runTui(undefined, undefined, undefined, skill)
-}
-
/**
* Run the interactive TUI from the invoking directory.
* @param config - an overlay patch list applied over the shared base and the
@@ -99,9 +78,8 @@ export async function runSkillSession(skill: string): Promise {
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
- * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
- * on a resume, so it never re-fires; reaches the app through
- * {@link INITIAL_SKILL_KEY}.
+ * first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume,
+ * so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
* to compose them; already parsed from `--config-replace`.
diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts
index 396a0e6ca5..cda7818e63 100644
--- a/apps/cli/tests/args.spec.ts
+++ b/apps/cli/tests/args.spec.ts
@@ -45,6 +45,29 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
})
+ it('routes the dump flags per surface: composed with the user layer, or shipped only', () => {
+ expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false })
+ expect(parse(['--dump-config', '--config', 'c.yml']))
+ .toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' })
+ expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true })
+ expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false })
+ expect(parse(['web', '--dump-config', '--config', 'w.yml']))
+ .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' })
+ expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true })
+ // The two dump flags contradict each other; boot-only flags alongside a
+ // dump would be silently ignored; the shipped tree takes no user overlay.
+ expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1)
+ expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1)
+ expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1)
+ expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1)
+ expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1)
+ expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
+ expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
+ // A leaked dump flag on a subcommand that has none is a mistyped invocation.
+ expect(exitCode(['meta', '--dump-config'])).toBe(1)
+ expect(exitCode(['upgrade', '--dump-config'])).toBe(1)
+ })
+
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
// TUI inputs must not lose them. (Bad host/port are gated by the webserver
diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts
index d5bcbfd378..c9e1b29969 100644
--- a/apps/cli/tests/built-bin.e2e.ts
+++ b/apps/cli/tests/built-bin.e2e.ts
@@ -1,8 +1,9 @@
-import { existsSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
@@ -22,13 +23,20 @@ import { describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
-/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
-async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
- const result = await execa(process.execPath, [dshBin], {
+/**
+ * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
+ * + exit code. `env` isolates the Harness home for surfaces that read it.
+ */
+async function runBuiltBin(
+ args: readonly string[] = [],
+ env: Record = {},
+): Promise<{ stdout: string; code: number; stderr: string }> {
+ const result = await execa(process.execPath, [dshBin, ...args], {
input: '',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
+ env,
})
if (result.timedOut) {
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
@@ -45,4 +53,61 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
+
+ describe('dsh --dump-config', () => {
+ let home: string
+ beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
+ afterEach(() => { rmSync(home, { recursive: true, force: true }) })
+
+ it('prints the shipped TUI composition without booting or needing a TTY', async () => {
+ const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
+ expect(code).toBe(0)
+ expect(stderr).toBe('')
+ // Base rows composed with the TUI overlay's surface values, `!!js`
+ // expressions verbatim (unevaluated), and TUI-only inserted rows present.
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
+ expect(stdout).toContain('model: deepseek-v4-pro')
+ expect(stdout).toContain('cwd: !!js process.cwd()')
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
+ // Provenance comment separators name each section's source file.
+ expect(stdout).toContain('# == base.cordis.yml')
+ expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')
+ expect(stdout).toContain('# == tui.cordis.yml')
+ }, 30_000)
+
+ it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
+ writeFileSync(join(home, 'config.yaml'), [
+ '- id: agent-loop',
+ ' config:',
+ ' agents:',
+ ' - id: main',
+ ' provider: custom-provider',
+ ' model: custom-model',
+ '- id: only-on-web',
+ ' config:',
+ ' value: 1',
+ '',
+ ].join('\n'))
+ const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
+ expect(code).toBe(0)
+ expect(stdout).toContain('provider: custom-provider')
+ expect(stdout).not.toContain('model: deepseek-v4-pro')
+ // The personal layer appears in the patched row's provenance and the
+ // skipped-patch warning carries its label.
+ expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
+ expect(stderr).toContain('patch: entry "only-on-web" not found')
+
+ // The shipped view ignores the personal overlay entirely.
+ const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
+ expect(shipped.stdout).not.toContain('custom-provider')
+ expect(shipped.stdout).toContain('model: deepseek-v4-pro')
+ }, 30_000)
+
+ it('composes the web overlay for `dsh web --dump-config`', async () => {
+ const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
+ expect(code).toBe(0)
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
+ expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'")
+ }, 30_000)
+ })
})
diff --git a/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
new file mode 100644
index 0000000000..00878d6c98
--- /dev/null
+++ b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
@@ -0,0 +1,8 @@
+# Minimal keyless composition for loading example MCP overlays against the
+# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins
+# keep this unit test independent of prebuilt workspace artifacts.
+- id: system-prompt
+ name: cordis:memory-test-system-prompt
+
+- id: tools
+ name: cordis:memory-test-tools
diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts
new file mode 100644
index 0000000000..7069b22738
--- /dev/null
+++ b/apps/cli/tests/memory-mcp-configs.spec.ts
@@ -0,0 +1,132 @@
+/**
+ * The third-party memory examples stay config-only. This suite parses every
+ * checked-in overlay, verifies its pin/transport/secret boundary, then replaces
+ * only the upstream endpoint with the package-owned keyless MCP fixture and
+ * proves the real Cordis Loader discovers a tool through the generic bridge.
+ */
+
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { Context } from 'cordis'
+import type { PatchOptions } from '@cordisjs/plugin-include'
+import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts'
+
+interface ExampleContract {
+ file: string
+ id: string
+ serverName: string
+ transport: 'stdio' | 'streamable-http'
+ pin: string
+}
+
+interface InsertedRow {
+ id?: string
+ name?: string
+ config?: Record
+}
+
+const root = resolve(import.meta.dirname, '../../..')
+const exampleDir = resolve(root, 'examples/mcp-memory')
+const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml')
+const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts')
+
+const examples: ExampleContract[] = [
+ {
+ file: 'memorix.cordis.yml',
+ id: 'memory-memorix',
+ serverName: 'memorix',
+ transport: 'stdio',
+ pin: '1.3.0',
+ },
+ {
+ file: 'mcp-reference-memory.cordis.yml',
+ id: 'memory-mcp-reference',
+ serverName: 'reference_memory',
+ transport: 'stdio',
+ pin: '2026.7.4',
+ },
+ {
+ file: 'engram.cordis.yml',
+ id: 'memory-engram',
+ serverName: 'engram',
+ transport: 'stdio',
+ pin: '1.20.0',
+ },
+]
+
+const liveContexts = new Set()
+
+afterEach(async () => {
+ await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose()))
+ liveContexts.clear()
+})
+
+function insertedRow(patches: PatchOptions[]): InsertedRow {
+ expect(patches).toHaveLength(1)
+ const insert = patches[0]?.insert
+ expect(insert).toHaveLength(1)
+ return insert?.[0] as InsertedRow
+}
+
+async function waitForTool(ctx: Context, name: string): Promise {
+ const deadline = Date.now() + 10_000
+ while (!ctx.tools.schemas().some(schema => schema.name === name)) {
+ if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`)
+ await new Promise(resolveWait => setTimeout(resolveWait, 25))
+ }
+}
+
+describe('third-party memory MCP example overlays', () => {
+ it.each(examples)('parses $file with the documented generic boundary', (contract) => {
+ const file = resolve(exampleDir, contract.file)
+ const source = readFileSync(file, 'utf8')
+ const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
+
+ expect(row.id).toBe(contract.id)
+ expect(row.name).toBe('@deepseek-ai/dsh-mcp-client')
+ expect(row.config?.serverName).toBe(contract.serverName)
+ expect(row.config?.transport).toBe(contract.transport)
+ expect(source.split('\n', 1)[0]).toContain(contract.pin)
+ expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/)
+ expect(source).not.toContain('DEEPSEEK_API_KEY')
+ })
+
+ it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => {
+ const patches = loadOverlayPatches(
+ 'memory-mcp-config-test',
+ resolve(exampleDir, contract.file),
+ )
+ // The static config gate verifies the checked-in bare package specifier.
+ // The unit test maps it to the source module so a clean checkout needs no
+ // prebuilt `lib/` artifacts before proving the Loader/MCP behavior.
+ insertedRow(patches).name = 'cordis:memory-test-mcp-client'
+ const fixturePatch: PatchOptions = {
+ id: contract.id,
+ config: {
+ serverName: contract.serverName,
+ transport: 'stdio',
+ command: process.execPath,
+ args: [fixtureServer],
+ env: {},
+ cwd: root,
+ toolCallTimeoutMs: 5_000,
+ },
+ }
+ const ctx = await boot(
+ 'memory-mcp-config-test',
+ baseConfig,
+ [...patches, fixturePatch],
+ (ctx) => {
+ liveContexts.add(ctx)
+ ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt
+ ctx.loader.builtins['memory-test-tools'] = ToolRegistry
+ ctx.loader.builtins['memory-test-mcp-client'] = McpClient
+ },
+ )
+ await waitForTool(ctx, `mcp__${contract.serverName}__greet`)
+ }, 15_000)
+})
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
new file mode 100644
index 0000000000..fae329362b
--- /dev/null
+++ b/apps/web/tests/access-confirmation.e2e.ts
@@ -0,0 +1,108 @@
+// Web e2e scenario: every visible permission picker gates Full access behind
+// the same locale-aware, in-page risk confirmation. Zero model calls: the
+// scenario boots the shipped Web composition and exercises the real
+// permission projection, client command path, HTTP RPC, and pushed update.
+import { mkdirSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+
+/**
+ * connectFreshWorkspace twin over the product default Chinese locale (the
+ * shared helper's anchors assume the English page every other scenario
+ * boots; this scenario deliberately keeps zh, so the localized picker
+ * copy is the anchor set).
+ */
+async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise {
+ mkdirSync(join(root, name), { recursive: true })
+ await page.getByRole('button', { name: '选择工作区' }).click()
+ const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByRole('button', { name: '编辑路径' }).click()
+ const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
+ await pathInput.fill(join(root, name))
+ await pathInput.press('Enter')
+ await dialog.getByRole('button', { name: '打开', exact: true }).click()
+ await page.locator('textarea:enabled[placeholder="æ��è¿°ä½ æƒ³è¦�构建的内容"]')
+ .waitFor({ timeout: 15_000 })
+}
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Full access confirmation', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ // CI uses Playwright's pinned browser. A developer may point this one
+ // scenario at an installed Chromium when the matching browser download
+ // is temporarily unavailable.
+ const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
+ browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
+ // Keep the product default Chinese locale: the golden pins the actual
+ // registered dictionary rather than a test-local translation callback.
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('requires acknowledgement before the composer picker can enable Full access', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
+ const access = page.locator('button[aria-label^="访问模�"]').first()
+ await access.waitFor({ timeout: 10_000 })
+
+ // Normalize the starting preset through the real command path. The
+ // shipped web config may already start at Full access.
+ if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
+ await access.click()
+ await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('访问模�,当�:Workspace Write')
+ }
+
+ await access.click()
+ await page.getByRole('menuitem', { name: 'Full access' }).click()
+ const dialog = page.getByRole('dialog', { name: '确认�用 Full access?' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const enable = dialog.getByRole('button', { name: '�用 Full access' })
+ expect(await enable.isDisabled()).toBe(true)
+
+ // The modal is in this page's body (not a native/new window) and escapes
+ // the sticky composer's stacking context.
+ expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+ await dialog.getByRole('checkbox', { name: '我已了解风险,并愿æ„�ç»§ç»' }).check()
+ expect(await enable.isEnabled()).toBe(true)
+ await enable.click()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('访问模�,当�:Full access')
+ expect(await dialog.count()).toBe(0)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('keeps its snapshot inventory closed', async () => {
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts
index b66277e0b2..7a2a684765 100644
--- a/apps/web/tests/approval-composer.e2e.ts
+++ b/apps/web/tests/approval-composer.e2e.ts
@@ -61,7 +61,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
- await connectFreshWorkspace(page)
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 0bac8a2eb8..d2f6d913dd 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -111,6 +111,44 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
+ // The write/edit turns render a real diff card through the assembled graph
+ // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
+ // fixture's raw text. The card is collapsed by default, so expand each edit/
+ // write row first. The write turn's `hello fixture\n` proves the terminator
+ // rule end to end: a trailing newline terminates its line, so the footer reads
+ // `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
+ // ::before, so it is absent from textContent — assert on the line body and the
+ // footer.
+ const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
+ expect(mutationRows.length).toBeGreaterThan(0)
+ for (const row of mutationRows) {
+ const toggle = row.querySelector('[data-expandable]')
+ if (toggle !== null) act(() => { fireEvent.click(toggle) })
+ }
+ const diffCards = [...document.querySelectorAll('[data-diff]')]
+ expect(diffCards.length).toBeGreaterThan(0)
+ const footers = diffCards.map(card => card.textContent ?? '')
+ expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
+
+ // The web render intent reaches the assembled boot graph: the fixture's
+ // web_search / web_fetch turns render their keyed WebRow cards, proving the
+ // registration, wire projection, and card rendering survive the real bundle
+ // path (not just the per-package src benches). WebRow composes ToolRow, so the
+ // card is collapsed behind the row; the keyed row is pinned by its `data-tool`
+ // (ToolRow sets it from the wire tool name).
+ const webSearchRow = await waitFor(() => {
+ const row = document.querySelector('[data-tool="web_search"]')
+ expect(row).not.toBeNull()
+ expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
+ return row!
+ }, { timeout: 10_000 })
+ // Expand the web_search row to prove its WebBlock card renders end to end.
+ const webToggle = webSearchRow.querySelector('[data-expandable]')
+ if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
+ await waitFor(() => {
+ expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
+ }, { timeout: 10_000 })
+
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts
index 78f27acaca..6a0379de94 100644
--- a/apps/web/tests/code-mode-round.e2e.ts
+++ b/apps/web/tests/code-mode-round.e2e.ts
@@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
- await connectFreshWorkspace(page)
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts
new file mode 100644
index 0000000000..e7dc1da238
--- /dev/null
+++ b/apps/web/tests/composer-draft-scroll.e2e.ts
@@ -0,0 +1,397 @@
+// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
+// GLYPHS, not just its caret.
+//
+// The composer paints its text in two stacked layers (see
+// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
+// `