Преглед на файлове

Merge pull request #3605 from deepseek-harness/fix/ptc-bash-cards

fix(web): show Bash terminal cards in PTC mode
Tianyi Cui преди 1 месец
родител
ревизия
a6e157c0a3
променени са 40 файла, в които са добавени 579 реда и са изтрити 95 реда
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
  5. 18 14
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
  6. 18 14
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.i18n.yaml
  8. 35 0
      .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md
  9. 35 0
      .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml
  11. 2 2
      .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md
  12. 2 2
      .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md
  13. 32 8
      apps/web/tests/ptc-round.e2e.ts
  14. 2 2
      docs/config-catalog.i18n.yaml
  15. 1 1
      docs/config-catalog.md
  16. 1 1
      docs/config-catalog.zh.md
  17. 1 1
      packages/client/tsdown.client.ts
  18. 2 2
      packages/client/ui-tool/README.i18n.yaml
  19. 4 2
      packages/client/ui-tool/README.md
  20. 4 2
      packages/client/ui-tool/README.zh.md
  21. 8 0
      packages/client/ui-tool/package.json
  22. 1 1
      packages/client/ui-tool/src/client/tool/ToolDetails.tsx
  23. 21 6
      packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
  24. 4 4
      packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
  25. 7 0
      packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
  26. 148 0
      packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts
  27. 50 12
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  28. 3 0
      packages/client/ui-tool/tsconfig.json
  29. 2 2
      packages/spill/spill-policy/README.i18n.yaml
  30. 7 0
      packages/spill/spill-policy/README.md
  31. 7 0
      packages/spill/spill-policy/README.zh.md
  32. 5 0
      packages/spill/spill-policy/package.json
  33. 4 9
      packages/spill/spill-policy/src/index.ts
  34. 52 0
      packages/spill/spill-policy/src/notice.ts
  35. 47 0
      packages/spill/spill-policy/tests/notice.spec.ts
  36. 24 0
      pnpm-lock.yaml
  37. 7 0
      scripts/client-bundle-purity.spec.ts
  38. 8 2
      snapshots/web/ptc-round/ui.expected.md
  39. 1 0
      tsconfig.base.json
  40. 2 0
      tsconfig.client.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md
-2026-07-20-canonical-tool-output-contract.md: 52b56a44a747a07e13e05511d6d40a85fcd9696d
-2026-07-20-canonical-tool-output-contract.zh.md: a78defb2b45143d46fed2bedf760df12744621a5
+2026-07-20-canonical-tool-output-contract.md: f2c17325f77b93675086c39dd5a7693854b8521a
+2026-07-20-canonical-tool-output-contract.zh.md: d81c1730aab66df2dcf4eea4515f260df917ffb4

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md

@@ -34,7 +34,7 @@ type ToolExecutionResult =
 
 `tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value.
 
-Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
+Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw arguments and rendered content without that metadata. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
 
 The first-party tools preserve their existing Native text while returning domain DTOs:
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md

@@ -34,7 +34,7 @@ type ToolExecutionResult =
 
 `tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。
 
-规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
+规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据。Client 可以从原始参数与渲染后的内容派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需这些元数据。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
 
 第一方工具在保持现有 Native 文本不变的同时返回领域 DTO:
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: ada444ab06bebd3e50018cea4a8dcde6b13956c4
-2026-08-23-client-derived-tool-presentation.zh.md: 8c1d60d5ed11e79b4428c7245f0db9c6b91c8b2c
+2026-08-23-client-derived-tool-presentation.md: 58a8f23d717580355b852703f448c723d9c3a7ea
+2026-08-23-client-derived-tool-presentation.zh.md: 8e1a02eff57cce7967985a22c5bdcec7c18e6059

+ 18 - 14
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md

@@ -22,6 +22,8 @@ The required result is one raw Session journal and one Client presentation owner
 
 ## Decision
 
+The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain.
+
 The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
 
 The Client Conversation layer continues to own tool call/result identity, pairing, lifecycle, Code Dispatch topology, and stable Chat Nodes. It does not interpret individual tool names or produce terminal, diff, read, search, or web component props.
@@ -49,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
 | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
 | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
 | Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
-| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements |
+| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) |
 | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
 
 ## Terminology
@@ -242,13 +244,13 @@ The Chat and Trajectory Tool Definitions read no views. They derive the followin
 
 ### Root and Code Dispatch subcalls
 
-Host presenter APIs describe top-level calls and results. Code Dispatch subcalls use the Generic, flattened Client presentation; recognizing a subcall name does not grant it a structured card.
+Host presenter APIs describe top-level calls and results. Code Dispatch subcalls retain Generic, flattened presentation for the diff, read, search, and web models covered here; supported terminal calls use the same eligibility rules as roots.
 
-Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The five structured card models accept only blocks without `parentCallId`, while existing renderers that intentionally support nested calls continue receiving the same child block.
+Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The diff, read, search, and web models accept only blocks without `parentCallId`; the terminal model and existing renderers that intentionally support nested calls accept child blocks.
 
-The Details panel delegates the selected block unchanged. The same card models observe `parentCallId` and keep a selected Code Dispatch child on the existing raw fallback, so the Details slot needs no placement field.
+The Details panel delegates the selected block unchanged. Shared card models apply the same terminal eligibility and nonterminal child restrictions in rows and Details, so the Details slot needs no placement field.
 
-The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` controls only the terminal, diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
+The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` restricts only the diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
 
 ### Missing call head
 
@@ -294,7 +296,7 @@ The title, kind, rawInput, content, and locations from Generic Host `presentCall
 
 ### Terminal card
 
-The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, existing `parentCallId`, and Session cwd.
+The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`.
 
 | Input | Preserved result |
 |---|---|
@@ -306,9 +308,9 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
 | settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card |
 | foreground `terminal_send` | terminal prompt and output |
 | background/error `terminal_send` | Generic result |
-| Code Dispatch child | current flattened Generic form |
+| Code Dispatch child | same terminal eligibility and fallback rules as a root call |
 
-Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body.
+Standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body.
 
 Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
 
@@ -418,7 +420,7 @@ The fixture does not import Host tool packages to compute page presentation and
 | grep/glob | current grouped/path card, truncation, and recovery |
 | web_search/web_fetch | current source/summary card and raw body |
 | Todo/Question/Skill/Cordis | current specialized rows |
-| Code Dispatch subcall | current Generic/flattened form |
+| Code Dispatch subcall | terminal cards when eligible; diff/read/search/web remain Generic/flattened |
 | Chat and Details | identical card fields for the same call |
 | Trajectory | current identity, tree, selection, and details |
 | Deliverables | current successful-mutation chips and links |
@@ -526,7 +528,7 @@ This change does not promise to preserve differences expressed only through a Ho
 - search produces the pinned grouped/path card and recovery from metadata/content.
 - web produces the pinned sources/fetch summary from metadata/content.
 - unknown, malformed, error, missing-call, and missing-metadata cases remain Generic.
-- absent and present `parentCallId` cases prove that structured presentation does not reach Code Dispatch descendants.
+- absent and present `parentCallId` cases prove equal terminal eligibility and preserve Generic fallback for diff, read, search, and web descendants.
 - Chat and Details produce identical card fields for the same block.
 
 ### Deliverables
@@ -582,12 +584,12 @@ Changes to this decision use `dsh-pre-push-checks` to select commands for the fi
 - Result metadata passes byte-for-byte through the log and Remote to the Client.
 - Conversation assembles `ToolCallBlock` only from raw events.
 - `ToolCallBlock` contains no Host render-intent fields.
-- The five structured card models read only raw blocks, their existing `parentCallId`, and Session path facts.
+- The five structured card models read only raw blocks and Session path facts; only diff, read, search, and web use `parentCallId` to reject children.
 - Generic, Todo, Question, Skill, and Cordis rows remain unchanged.
 - Deliverables does not depend on render intent and preserves current paths.
 - Text, components, expanded content, states, links, and ordering for all first-party top-level tools remain unchanged.
 - Malformed, missing-metadata, error, orphan, and unknown-tool cases continue to fall back safely.
-- Code Dispatch subcalls remain Generic and flattened.
+- Code Dispatch diff, read, search, and web subcalls remain Generic and flattened; terminal subcalls follow root eligibility.
 - Chat, Details, and Trajectory behavior remains unchanged.
 - Existing Web browser expected outputs pass without refresh.
 - Host presenter APIs, implementations, and direct tests remain unchanged.
@@ -632,7 +634,7 @@ An on-demand RPC would turn one page read into N network calls and would still r
 
 ### Allow presentation enhancements
 
-The Client could produce more rich cards for Code Dispatch subcalls, missing call heads, or history whose Host presenter was unavailable. That would mix an ownership change with product behavior and prevent snapshots from proving equivalence, so this alternative is rejected.
+Bundling richer Code Dispatch cards, missing-call-head inference, or other historical presentation enhancements with the ownership change would prevent snapshots from proving equivalence. This decision rejects that coupling; the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) does not relax nonterminal child restrictions.
 
 ### Accept temporary Generic degradation
 
@@ -684,6 +686,8 @@ The absence of optional `view` is a prerelease wire-type decision shared by all
 
 ## Relationship to Existing Decisions
 
+[Nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) partially supersedes only the terminal child-card prohibition and its visual-equivalence requirement. This note remains active for raw-journal ownership, Client derivation, and the diff/read/search/web child restrictions.
+
 This note partially supersedes the implementation fact in [Client tool presentation ownership](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) that “card models receive Host views.” Its core decisions remain: `ui-tool` owns presentation, business plugins use keyed slots, and Conversation owns only lifecycle and topology.
 
 This note preserves [toolview dissolution](../../archived/architecture/2026-07-23-toolview-dissolution.md): the Client still has one slot registration model and does not restore `ToolViewRegistry`.
@@ -699,7 +703,7 @@ This note preserves result metadata from the [canonical tool output contract](20
 ## Deferred
 
 - A separate explicit decision may evaluate deleting Host presenters if they remain without production consumers; this decision does not prejudge it.
-- Specialized cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; this decision preserves current behavior.
+- Specialized diff, read, search, and web cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; terminal calls are covered by the linked partial supersession.
 - A third-party mutation tool that joins Deliverables requires a new Client-owned contribution; this decision does not create a registry for an absent consumer.
 - Distinct Client presentation for same-named providers first requires a stable, non-presentational identity; it must not restore per-page Host views.
 - If Client card-model performance needs measurement, an immutable-block microbenchmark can be added; the shipped architecture already prohibits scanning the Session window.

+ 18 - 14
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md

@@ -22,6 +22,8 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
 
 ## Decision
 
+下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。
+
 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
 
 Client Conversation 层继续负责工具调用与结果的 identity、配对、生命周期、Code Dispatch 拓扑和稳定 Chat Node。它不解释具体工具名称,也不生成 terminal、diff、read、search 或 web 组件 props。
@@ -49,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
 | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
 | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
 | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
-| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强 |
+| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 |
 | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
 
 ## 术语
@@ -242,13 +244,13 @@ Chat 和 Trajectory 的 Tool Definition 都不读取 view,而从事件生成
 
 ### Root 与 Code Dispatch 子调用
 
-Host presenter API 描述顶层 call/result。Code Dispatch 子调用使用 Generic/flattened Client 展示;Client 能识别子调用名称并不赋予它结构化卡片。
+Host presenter API 描述顶层 call/result。本决定覆盖的 diff、read、search 和 web model 对 Code Dispatch 子调用保留 Generic/flattened 展示;受支持的 terminal 调用使用与根调用相同的适用规则。
 
-Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。五类结构化 card model 只接受没有 `parentCallId` 的 block,原本有意支持嵌套调用的 renderer 则继续收到同一个 child block。
+Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。diff、read、search 和 web model 只接受没有 `parentCallId` 的 block;terminal model 与原本有意支持嵌套调用的 renderer 接受 child block。
 
-Details panel 原样委托选中的 block。同一组 card model 读取 `parentCallId`,让选中的 Code Dispatch child 保持现有 raw fallback,因此 Details slot 不需要 placement 字段。
+Details panel 原样委托选中的 block。共享 card model 在行与 Details 中应用相同的 terminal 适用规则和非 terminal 子调用限制,因此 Details slot 不需要 placement 字段。
 
-keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只控制本决定覆盖的 terminal/diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
+keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只限制本决定覆盖的 diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
 
 ### 缺失调用头
 
@@ -294,7 +296,7 @@ Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 
 
 ### Terminal 卡片
 
-Client terminal model 从工具名称、调用参数、结果 content、error、现有 `parentCallId` 与 Session cwd 派生现有 `TerminalBlock` props。
+Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。
 
 | 输入 | 保持的结果 |
 |---|---|
@@ -306,9 +308,9 @@ Client terminal model 从工具名称、调用参数、结果 content、error、
 | persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card |
 | `terminal_send` 前台 | terminal prompt 与 output |
 | `terminal_send` background/error | Generic 结果 |
-| Code Dispatch child | 当前 flattened Generic 形态 |
+| Code Dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 |
 
-标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
+标准 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
 
 调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
 
@@ -418,7 +420,7 @@ fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 
 | grep/glob | 当前 grouped/path card、截断与 recovery |
 | web_search/web_fetch | 当前来源/摘要 card 与原始正文 |
 | Todo/Question/Skill/Cordis | 当前专用行 |
-| Code Dispatch subcall | 当前 Generic/flattened 形态 |
+| Code Dispatch subcall | 满足条件时显示 terminal 卡片;diff/read/search/web 保持 Generic/flattened |
 | Chat 与 Details | 同一调用使用相同 card fields |
 | Trajectory | 当前 identity、树、选择和 details |
 | Deliverables | 当前成功 mutation chips 与链接 |
@@ -526,7 +528,7 @@ Host registry 允许不同 scope 为同一 tool name 提供不同定义;Sessio
 - search 用 meta/content 得到已固定的 grouped/path card 与 recovery。
 - web 用 meta/content 得到已固定的 sources/fetch summary。
 - unknown、malformed、error、missing-call 与 missing-meta 继续 Generic。
-- `parentCallId` 缺失与存在的用例证明结构化展示不会到达 Code Dispatch descendant。
+- `parentCallId` 缺失与存在的用例证明 terminal 适用规则一致,并保留 diff、read、search 和 web descendant 的 Generic fallback。
 - Chat 与 Details 对同一 block 得到相同 card fields。
 
 ### Deliverables
@@ -582,12 +584,12 @@ Host registry 允许不同 scope 为同一 tool name 提供不同定义;Sessio
 - result meta 逐字节通过日志与 Remote 到达 Client。
 - Conversation 只从 raw event 组装 ToolCallBlock。
 - ToolCallBlock 不含 Host render-intent 字段。
-- 五类结构化 card model 只读 raw block、其现有 `parentCallId` 与 Session path facts。
+- 五类结构化 card model 只读 raw block 与 Session path facts;只有 diff、read、search 和 web 使用 `parentCallId` 拒绝子调用。
 - Generic、Todo、Question、Skill 与 Cordis 行行为不变。
 - Deliverables 不依赖 render intent 且保持当前 paths。
 - 所有第一方顶层工具的文本、组件、展开内容、状态、链接与排序不变。
 - malformed、missing-meta、error、orphan 与 unknown-tool 继续安全 fallback。
-- Code Dispatch 子调用保持 Generic/flattened。
+- Code Dispatch 的 diff、read、search 和 web 子调用保持 Generic/flattened;terminal 子调用遵循根调用适用规则。
 - Chat、Details 与 Trajectory 行为不变。
 - 现有 Web browser expected 无需刷新即可通过。
 - Host presenter API、实现与直接测试不变。
@@ -632,7 +634,7 @@ read 行结构、applied diff、search 分组、web sources 和有效 truncation
 
 ### 允许展示增强
 
-Client 可以为 Code Dispatch 子调用、缺失 call head 或 Host presenter 不可用的历史生成更多 rich card,但这会混淆 ownership 变化与产品行为,并使快照无法证明对等,因此拒绝。
+将更丰富的 Code Dispatch 卡片、缺失 call head 的推断或其他历史展示增强与所有权变更捆绑,会使快照无法证明对等。本决定拒绝这种捆绑;[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)不放宽非 terminal 子调用限制。
 
 ### 接受临时 Generic 退化
 
@@ -684,6 +686,8 @@ optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型
 
 ## 与现有决策的关系
 
+[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)仅部分取代 terminal 子调用卡片禁令及其展示对等要求。本文继续负责原始 journal 所有权、Client 派生以及 diff/read/search/web 子调用限制。
+
 本文部分取代 [Client 工具展示所有权](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) 中“card model 接收 Host view”的实现事实;`ui-tool` 拥有展示、业务插件使用 keyed slot、Conversation 只拥有生命周期与拓扑的核心决定保持不变。
 
 本文保留 [toolview 溶解](../../archived/architecture/2026-07-23-toolview-dissolution.md) 的决定:Client 仍只有 slot 注册模型,不恢复 `ToolViewRegistry`。
@@ -699,7 +703,7 @@ optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型
 ## Deferred
 
 - Host presenter 若长期没有生产消费者,可由另一项明确决策评估删除;本决定不预判。
-- Code Dispatch 子调用若要专用卡片,需单独设计并更新可见快照;本决定保持现状。
+- Code Dispatch 子调用的 diff、read、search 和 web 专用卡片仍需独立设计并更新可见快照;terminal 调用由链接的部分取代决策负责。
 - 第三方 mutation tool 若要加入 Deliverables,需新增 Client-owned 贡献;本决定不为尚无消费者的扩展性建 registry。
 - 同名 provider 若要不同 Client 展示,需先定义稳定、非展示性的 identity;不得恢复按页 Host view。
 - Client card model 若需量化性能,可以增加 immutable-block 微基准;已交付架构禁止扫描 Session window。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md
+2026-09-05-nested-terminal-cards.md: b369aef20b0b5fbb338e40affd5fd2800b525bcb
+2026-09-05-nested-terminal-cards.zh.md: bf97fffa2a8d92562e982d498d17b830e1e25f48

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md

@@ -0,0 +1,35 @@
+# Agent Note: Nested terminal cards
+
+Status: implemented
+
+English | [中文](2026-09-05-nested-terminal-cards.zh.md)
+
+## Problem
+
+A shell command dispatched through `run_code` carries the arguments and rendered output needed for a terminal card, but rejecting every block with `parentCallId` hides that presentation solely because the call is nested. The rejection also affects running prompts and selected-child Details.
+
+## Decision
+
+`terminalCardModel` applies the same eligibility checks to root and Code Dispatch calls, without rejecting `parentCallId`. Supported running and settled `bash`, `pwsh`, and `terminal_send` calls use the existing terminal card. Background calls, tool errors, malformed inputs, missing call heads, and unsupported result content retain generic fallback. Persistent shells remain eligible while running and generic when settled; a nonzero process exit remains terminal result data rather than a tool error.
+
+This partially supersedes only the terminal child-card prohibition in [Client-derived tool presentation](../architecture/2026-08-23-client-derived-tool-presentation.md). That note remains active for Client presentation ownership and the diff/read/search/web child restrictions. No Host presenter, event, schema, metadata, call-tree, or model-context change is required. The metadata and execution-local value decisions in [canonical tool output](../architecture/2026-07-20-canonical-tool-output-contract.md) and [PTC typed returns](../feature/2026-07-20-ptc-typed-tool-returns.md) remain intact; metadata omission does not prohibit Client-derived terminal cards.
+
+Shell output ending in a recognized spill-policy notice uses generic output: expandable in `BashRow`, raw fallback in Details. The notice can follow or replace the exit marker, so its absence at the end does not justify a successful terminal status. The browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry owns the text convention: the producer calls `formatSpillNotice(omitted, ref)` and the Client calls `hasSpillNotice(text)`. Both share delimiters, and omission validation reuses `describeOmitted` rather than duplicating its prose. The formatter preserves the persisted spelling byte-for-byte; existing Session result bytes stay untouched, with no Session format change or migration.
+
+## Alternatives considered
+
+**Keep the blanket nested-call rejection.** Rejected because nesting does not remove the raw facts the terminal model already consumes. It hides usable shell output while the same call renders as a terminal at the root.
+
+**Enable every nested structured card.** Rejected because other card models have independent metadata requirements and child restrictions. This fix changes only terminal eligibility.
+
+**Parse exit markers around spill suffixes.** Rejected because truncation can remove the real status; conservative generic output avoids guessing success from an incomplete result.
+
+**Maintain a separate UI notice regex.** Rejected because it duplicates the producer's text convention and can drift from persisted output. The shared browser-safe owner keeps formatting and recognition together without loading the Host plugin in the browser.
+
+## Consequences
+
+Rows and Details share terminal derivation for nested calls without a second renderer or presentation hint. Generic fallback and settled-persistent behavior remain separate from terminal-card eligibility. The parent-child relationship still controls tree placement, not terminal rendering. Text recognition cannot authenticate output: a tool can print the same notice. A match selects conservative generic presentation, not proof of spill provenance or process status.
+
+## Verification
+
+The [terminal card specs](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx) cover root/child eligibility, running and settled Details, and fallback cases. The [assembled Code Dispatch specs](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx) cover nested terminal rendering through the conversation tree. The [notice specs](../../../../packages/spill/spill-policy/tests/notice.spec.ts) pin the historical spelling with a literal fixture independent of the formatter. The [spill-policy-to-UI specs](../../../../packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts) exercise actual root and PTC spill production, unchanged full text and programmatic values, byte caps, notice-only output, and terminal fallback. Browser replay owns the visible nested-card change; nonterminal child behavior remains outside this fix.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 嵌套 terminal 卡片
+
+Status: implemented
+
+[English](2026-09-05-nested-terminal-cards.md) | 中文
+
+## 问题
+
+经 `run_code` 分发的 shell 命令携带 terminal 卡片所需的参数与渲染输出,但对所有带有 `parentCallId` 的块一律拒绝,会仅因调用嵌套而隐藏这类展示。该拒绝也影响运行中的命令提示行与选中子调用的 Details。
+
+## 决策
+
+`terminalCardModel` 对根调用与 Code Dispatch 调用应用相同的适用检查,不因 `parentCallId` 拒绝调用。受支持的运行中与已完成的 `bash`、`pwsh` 和 `terminal_send` 调用使用现有 terminal 卡片。后台调用、工具错误、格式错误的输入、缺失的调用头和不受支持的结果内容保留通用回退。持久 shell 在运行中仍可使用 terminal,完成后使用通用展示;非零进程退出仍是 terminal 结果数据,而非工具错误。
+
+本文仅部分取代 [Client 派生工具展示](../architecture/2026-08-23-client-derived-tool-presentation.zh.md)中的 terminal 子调用卡片禁令。该文继续负责 Client 展示所有权及 diff/read/search/web 子调用限制。无需更改 Host 展示转换器、事件、schema、元数据、调用树或模型上下文。[规范工具输出](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)与 [PTC 类型化返回值](../feature/2026-07-20-ptc-typed-tool-returns.zh.md)中的元数据和执行期值决策保持不变;省略元数据不禁止 Client 派生 terminal 卡片。
+
+以已识别的 spill 策略提示结尾的 shell 输出使用通用展示:在 `BashRow` 中可展开,在 Details 中使用原始回退。提示可能位于退出标记之后或取代它,因此末尾缺少退出标记不能作为 terminal 成功状态的依据。浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 负责文本约定:生产方调用 `formatSpillNotice(omitted, ref)`,Client 调用 `hasSpillNotice(text)`。两者共用分隔符,省略信息校验复用 `describeOmitted`,不复制其文案。格式化函数逐字节保留持久化拼写;现有 Session 结果字节保持不变,不更改 Session 格式,也不执行迁移。
+
+## 考虑过的替代方案
+
+**保留对嵌套调用的一律拒绝。** 不予采用,因为嵌套不会移除 terminal model 已消费的原始事实。这会隐藏可用的 shell 输出,而同一调用位于根时却可渲染为 terminal。
+
+**启用所有嵌套结构化卡片。** 不予采用,因为其他 card model 有独立的元数据要求与子调用限制。本修复只改变 terminal 适用性。
+
+**解析 spill 后缀附近的退出标记。** 不予采用,因为截断可能移除真实状态;保守的通用输出避免从不完整结果猜测成功。
+
+**维护独立的 UI 通知正则表达式。** 不予采用,因为它复制生产方的文本约定,可能与持久化输出偏离。共享的浏览器安全模块统一负责格式化与识别,无需在浏览器中加载 Host 插件。
+
+## 后果
+
+行与 Details 对嵌套调用共享 terminal 派生,不增加第二个渲染器或展示提示字段。通用回退与已完成持久 shell 的行为仍独立于 terminal 卡片适用性。父子关系仍控制树中的位置,而非 terminal 渲染。文本识别无法认证输出来源:工具也能打印相同的提示。匹配结果只选择保守的通用展示,不能证明 spill 来源或进程状态。
+
+## 验证
+
+[Terminal 卡片测试](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx)覆盖根/子调用适用性、运行中与已完成的 Details 以及回退情况。[组装后的 Code Dispatch 测试](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx)覆盖经对话树渲染的嵌套 terminal。[通知测试](../../../../packages/spill/spill-policy/tests/notice.spec.ts)使用独立于格式化函数的字面量 fixture(测试前置数据)固定历史拼写。[spill-policy 到 UI 的测试](../../../../packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts)覆盖真实的根调用与 PTC spill 生成、保持不变的完整文本和程序化值、字节上限、仅含通知的输出以及 terminal 回退。浏览器回放负责验证可见的嵌套卡片变化;非 terminal 子调用行为不属于本修复。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.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-20-ptc-typed-tool-returns.md
-2026-07-20-ptc-typed-tool-returns.md: cc784ca8c9b1565135bc59c11ae0af973845fe5f
-2026-07-20-ptc-typed-tool-returns.zh.md: c4dd52d3c136569610beb8a65303809a1fe5fe4d
+2026-07-20-ptc-typed-tool-returns.md: 04ef9f7da4cd59ba07632684541dd6962008b810
+2026-07-20-ptc-typed-tool-returns.zh.md: c197d3131cc0f7fa326a9a47d945b2b7730f01c0

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md

@@ -75,7 +75,7 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu
 
 Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. This feature did not itself require a structural Session-format change; the released v0-to-v1 identity edge preserves these records, and replay still cannot recreate intermediate canonical program values.
 
-The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`.
+The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls; their canonical values never enter context. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw dispatch events without metadata. The outer `run_code` call produces the model-facing result and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`.
 
 ## Testing
 
@@ -112,5 +112,5 @@ The worker performs bounded-depth flat-wire transport and lossless validation bu
 - The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap.
 - Provider or executor acquisition limits may already have discarded source data before a canonical value reaches PTC mode.
 - Unsupported MCP output schemas fall back to `JsonValue`; admitted MCP images use the generic deferred projection, while audio and embedded-resource payloads remain diagnostic-only.
-- There is one result card per outer `run_code`, never per nested call.
+- Nested calls have no separate model-facing result; Client child cards do not add model context.
 - Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md

@@ -75,7 +75,7 @@ PTC mode 通过运行时请求中的 `{ name: "ToolCallError", memberNamePropert
 
 嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。该功能本身不要求结构性 Session 格式变更;已发布的 v0-to-v1 恒等边会保留这些记录,回放仍无法重建程序的规范中间值。
 
-不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的 spill 投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。
+不透明的 `exec.parent` token 用于标识嵌套调用。展示元数据以及通用或工具自有的 spill 投影都会跳过这些调用;它们的规范值永远不会进入上下文。Client 可以从原始分发事件派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需元数据。外层 `run_code` 调用产生面向模型的结果,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。
 
 ## 测试
 
@@ -112,5 +112,5 @@ worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损
 - 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;spill 无法恢复超出该上限后被拒绝的字节。
 - 提供方或执行器的采集上限可能在规范值到达 PTC mode 前就已丢弃部分源数据。
 - 不支持的 MCP 输出 schema 会回退为 `JsonValue`;已准入的 MCP 图片使用通用延后投影,而音频和嵌入资源载荷仍只提供诊断。
-- 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。
+- 嵌套调用没有独立的面向模型的结果;Client 子调用卡片不会增加模型上下文。
 - PTC mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。

+ 32 - 8
apps/web/tests/ptc-round.e2e.ts

@@ -8,7 +8,7 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
@@ -102,18 +102,42 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
     expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel closed', async () => {
+  it.skipIf(MODE === 'record')('expands the nested bash terminal inline before and after reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-details'))
-    const nest = page.locator('[data-subcalls]').first()
-    const frame = page.locator('[style*="grid-template-columns"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
-    await expandOwningTurnProcess(page, nest)
-    await nest.locator('[data-sample="bash"]').first().click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+    let liveTerminalAria: string | undefined
+    for (const reloaded of [false, true]) {
+      if (reloaded) {
+        const warningStart = tripwire.warnings.length
+        await page.reload({ waitUntil: 'load' })
+        acknowledgeReloadConnectionLoss(tripwire, warningStart)
+        await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
+      }
+      const nest = page.locator('[data-subcalls]').first()
+      const frame = page.locator('[style*="grid-template-columns"]').first()
+      expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+      await expandOwningTurnProcess(page, nest)
+      const row = nest.locator('[data-sample="bash"]').first()
+      await expect.poll(() => row.getAttribute('data-state')).toBe('ok')
+      await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
+      await row.click()
+      await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
+      const terminal = row.locator('xpath=..').locator('[data-terminal]')
+      await terminal.waitFor()
+      await terminal.getByText('echo CODE_ROUND_OK', { exact: true }).waitFor()
+      await terminal.getByText('CODE_ROUND_OK', { exact: true }).waitFor()
+      await expect.poll(() => terminal.locator('[data-state]').getAttribute('data-state')).toBe('done')
+      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+      const aria = await terminal.ariaSnapshot()
+      if (reloaded) expect(aria).toBe(liveTerminalAria)
+      else liveTerminalAria = aria
+    }
   })
 
   it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-aria'))
+    const row = page.locator('[data-subcalls] [data-sample="bash"]').first()
+    await expandOwningTurnProcess(page, row)
+    if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
     const snapshot = await captureExpandedTurnProcessAria(
       page,
       '[class*="centerCol"]',

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

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

+ 1 - 1
docs/config-catalog.md

@@ -2192,7 +2192,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts)
+Source: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
 
 <a id="deepseek-aidsh-storage-domain"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -2194,7 +2194,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts)
+来源:[`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
 
 <a id="deepseek-aidsh-storage-domain"></a>
 

+ 1 - 1
packages/client/tsdown.client.ts

@@ -58,7 +58,7 @@ function styleInjectionModule(
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$)/
+export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
 
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below

+ 2 - 2
packages/client/ui-tool/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
-README.md: 6db5018801ef3f60ca25d97d51edac1538bff133
-README.zh.md: c7df60acaeeefb1afbb12f1ed481260de62905d2
+README.md: e0b49a3b582f5f1e6e9aa19e0b026dedb9ba2a18
+README.zh.md: 032f048f2501923c4c9f010b76afe27d98fe7889

+ 4 - 2
packages/client/ui-tool/README.md

@@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo
 
 ### Built-in views
 
-This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
 
 -----
 
@@ -62,7 +62,9 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
 ### Details and cards
 
 
-The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
+The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, and Session path facts. Diff, read, search, and web models also reject Code Dispatch children; terminal eligibility does not depend on `parentCallId`. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
+
+The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.
 </details>
 
 -----

+ 4 - 2
packages/client/ui-tool/README.zh.md

@@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 ### 内置视图
 
-本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 
 -----
 
@@ -62,7 +62,9 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 ### 详情与卡片
 
 
-本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
+本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata 与 Session 路径事实。diff、read、search 和 web model 还会拒绝 Code Dispatch 子调用;terminal 适用性不依赖 `parentCallId`。不受支持或格式错误的输入使用压平的工具结果文本。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
+
+terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责通知的格式化与识别。该检查保守地选择通用输出;匹配文本不能认证其来源,回放也不改变已记录的结果字节。
 </details>
 
 -----

+ 8 - 0
packages/client/ui-tool/package.json

@@ -48,6 +48,14 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-spill-policy": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-spill": "workspace:^",
+    "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",

+ 1 - 1
packages/client/ui-tool/src/client/tool/ToolDetails.tsx

@@ -16,7 +16,7 @@ import css from './ToolDetails.module.css'
 
 /**
  * Render the selected Tool call's structured output when its raw fields form a
- * supported root card, otherwise preserve the flattened result text.
+ * supported card, otherwise preserve the flattened result text.
  * @param props - selected call slice, workspace root, host home, and locale seat.
  * @returns the details output body.
  */

+ 21 - 6
packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts

@@ -2,6 +2,7 @@
 import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
+import { hasSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
 import type { ToolCallBlock } from './tool-call-model.ts'
 import { parsedToolCall, singleResultText, validEscalationFields } from './raw-tool-call.ts'
 
@@ -219,6 +220,21 @@ export function isSettledPersistentShellCall(block: ToolCallBlock): boolean {
   return shellCall(parsed.name, parsed.args)?.persistent === true
 }
 
+/**
+ * Identify a settled foreground shell preview whose spill footer can hide the exit marker.
+ * @param block - running or settled Tool block.
+ * @returns whether the shell output must remain generic without an inferred exit status.
+ */
+export function isSpilledShellCall(block: ToolCallBlock): boolean {
+  if (!('kind' in block)) return false
+  const parsed = parsedToolCall(block)
+  if (parsed === null) return false
+  const call = shellCall(parsed.name, parsed.args)
+  if (call === null || call.background) return false
+  const output = singleResultText(block)
+  return output !== undefined && hasSpillNotice(output)
+}
+
 interface TerminalSendCall {
   kind: 'terminal-send'
   text: string
@@ -255,10 +271,10 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
 }
 
 /**
- * Derive terminal props for supported root shell and terminal-send calls.
- * Standard shell results parse their final status marker; persistent shell
- * results, background calls, errors, malformed input, or child dispatches use
- * the generic path. {@link isSettledPersistentShellCall} lets that generic
+ * Derive terminal props for supported shell and terminal-send calls, including
+ * nested Code Dispatch calls. Standard shell results parse their final status
+ * marker; persistent shell results, spill previews, background calls, errors,
+ * and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic
  * persistent result remain expandable without inventing one process status.
  * @param block - running or settled Tool block.
  * @param sessionCwd - session workspace root used to resolve workdir.
@@ -268,7 +284,6 @@ export function terminalCardModel(
   block: ToolCallBlock,
   sessionCwd?: string,
 ): TerminalCardModel | null {
-  if (block.parentCallId !== undefined) return null
   const parsed = parsedToolCall(block)
   if (parsed === null) return null
   const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args)
@@ -290,7 +305,7 @@ export function terminalCardModel(
       },
     }
   }
-  if (block.isError || (call.kind === 'shell' && call.persistent)) return null
+  if (block.isError || (call.kind === 'shell' && call.persistent) || isSpilledShellCall(block)) return null
   const output = singleResultText(block)
   if (output === undefined) return null
   const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output)

+ 4 - 4
packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx

@@ -8,6 +8,7 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallViewProps } from '../../contract/slots.ts'
 import {
   isSettledPersistentShellCall,
+  isSpilledShellCall,
   localizeTerminalCardModel,
   terminalBlockLabels,
   terminalCardModel,
@@ -53,11 +54,10 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
     : model.state
   const status = stateStatus(state, t)
   const [expanded, setExpanded] = useState(false)
-  // Execution failures and persistent-shell results have no terminal card.
-  // Keep their recorded args and complete output reachable through the generic
-  // body; background acknowledgements and malformed calls remain collapsed.
+  // Failures, persistent-shell results, and spill previews use a generic body;
+  // background acknowledgements and malformed calls remain collapsed.
   const genericBody = terminal === null
-    && (model.state === 'error' || isSettledPersistentShellCall(block))
+    && (model.state === 'error' || isSettledPersistentShellCall(block) || isSpilledShellCall(block))
     && (model.bodyRaw !== null || model.output !== null)
   const expandable = terminal !== null || genericBody
   const open = expanded && expandable

+ 7 - 0
packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx

@@ -157,6 +157,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
     expect(view.getByText('Bash')).toBeTruthy()
     expect(view.getByText('List notes')).toBeTruthy()
     expect(view.getByText('Tool call')).toBeTruthy()
+
+    const bashRow = nest!.querySelector('[data-sample="bash"]')!
+    expect(bashRow.getAttribute('role')).toBe('button')
+    fireEvent.click(bashRow)
+    expect(nest!.querySelector('[data-terminal]')).not.toBeNull()
+    expect(view.getByText('ls notes')).toBeTruthy()
+    expect(nest!.querySelector('[data-terminal]')!.textContent).toContain('demo.txt')
   })
 
   it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {

+ 148 - 0
packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts

@@ -0,0 +1,148 @@
+/** Producer-to-UI regression for shell results bounded by the real spill policy. */
+import { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { WorkerThreadCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker-thread'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import { SpillLocator, SpillStore, type SaveTextSpill, type SpillRef } from '@deepseek-ai/dsh-spill'
+import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
+import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
+import { describe, expect, it } from 'vitest'
+import { isSpilledShellCall, terminalCardModel } from '../src/client/tool/models/terminal-card-model.ts'
+
+const spillReference = {
+  locator: SpillLocator('/spill/shell.txt'),
+  retrievalHint: 'Read the saved text.',
+}
+
+class MemorySpillStore extends SpillStore {
+  readonly saves: { input: SaveTextSpill; bytes: Buffer }[] = []
+
+  async saveText(input: SaveTextSpill): Promise<SpillRef> {
+    const bytes = Buffer.from(input.content, 'utf8')
+    this.saves.push({ input, bytes })
+    return {
+      ...spillReference,
+      bytes: bytes.length,
+    }
+  }
+}
+
+const shellArgs = { command: 'fixture-output', description: 'Return shell output fixture' }
+
+async function executeShell(text: string, nested: boolean, name = 'bash', maxInlineBytes = 256) {
+  const ctx = new Context()
+  try {
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRuntime, { mode: 'both' })
+    await ctx.plugin(MemorySpillStore)
+    await ctx.plugin(SpillPolicy, { maxInlineBytes })
+    if (nested) await ctx.plugin(WorkerThreadCodeRuntime, {})
+    ctx.effect(() => ctx.tools.register(defineContentToolFixture({
+      name,
+      description: 'Return deterministic shell text without spawning a process.',
+      parameters: {
+        command: { type: 'string', required: true },
+        description: { type: 'string', required: true },
+      },
+      async execute() { return [{ type: 'text', text }] },
+    })))
+    const session = Session.create(SessionId('spill-terminal'))
+    // The tool pipeline reads only the owner session; no agent loop runs here.
+    const agent = { session } as Agent
+    const callId = ToolCallId('shell-call')
+    const result = await ctx.tools.execute({
+      callId,
+      agent,
+      signal: new AbortController().signal,
+      name: nested ? 'run_code' : name,
+      arguments: nested ? {
+        code: `const blocks = await tools.${name}(${JSON.stringify(shellArgs)}); return blocks[0].text === ${JSON.stringify(text)};`,
+        description: 'Dispatch shell fixture through the tool pipeline',
+      } : shellArgs,
+    })
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('shell fixture execution failed')
+
+    let block: ToolResultNode
+    if (nested) {
+      expect(result.value).toMatchObject({ result: true })
+      const dispatches = session.snapshotEvents().filter(event => event.type === 'tool/code-dispatch')
+      expect(dispatches).toHaveLength(1)
+      const event = dispatches[0]!
+      block = {
+        kind: 'tool-result', seq: event.seq, time: event.time, callTime: null,
+        callId: event.data.subCallId, parentCallId: event.data.parentCallId,
+        call: { name: event.data.name, argsRaw: JSON.stringify(event.data.arguments) },
+        content: event.data.content, isError: event.data.isError, subCalls: [],
+      }
+    } else {
+      expect(result.value).toEqual([{ type: 'text', text }])
+      block = {
+        kind: 'tool-result', seq: 1, time: 1, callTime: null, callId,
+        call: { name, argsRaw: JSON.stringify(shellArgs) },
+        content: result.content, isError: result.isError, subCalls: [],
+      }
+    }
+    expect(block.isError).toBe(false)
+    const saves = (ctx.spillStore as MemorySpillStore).saves
+    return { block, saves }
+  } finally {
+    await ctx.fiber.dispose()
+  }
+}
+
+describe.each([
+  { location: 'root', nested: false },
+  { location: 'nested', nested: true },
+])('$location spill-policy shell terminal fallback', ({ nested }) => {
+  describe.each(['bash', 'pwsh'])('%s', (name) => {
+    it.each([
+      { outcome: 'nonzero exit', marker: '\n[exit code: 7]' },
+      { outcome: 'terminating signal', marker: '\n[killed by signal: SIGTERM]' },
+      { outcome: 'markerless output', marker: '' },
+    ])('keeps a spilled $outcome result generic', async ({ marker }) => {
+      const original = 'HEAD 雪\n'.repeat(200) + marker
+      const { block, saves } = await executeShell(original, nested, name)
+      expect(saves).toHaveLength(1)
+      expect(saves[0]!.input).toMatchObject({
+        owner: { sessionId: 'spill-terminal' },
+        source: { toolName: name, callId: block.callId, label: nested ? 'dispatch' : 'result' },
+        content: original,
+      })
+      expect(saves[0]!.bytes).toEqual(Buffer.from(original, 'utf8'))
+      expect(block.content).not.toEqual([{ type: 'text', text: original }])
+      expect(block.content).toHaveLength(1)
+      const preview = block.content[0]!
+      if (preview.type !== 'text') throw new Error('expected a plain-text spill preview')
+      expect(Buffer.byteLength(preview.text, 'utf8')).toBeLessThanOrEqual(256)
+      expect(preview.text).toContain('HEAD')
+      if (marker !== '') expect(preview.text).toContain(marker)
+      expect(terminalCardModel(block)).toBeNull()
+      expect(isSpilledShellCall(block)).toBe(true)
+    })
+
+    it('keeps a notice-only result generic when no preview fits', async () => {
+      const original = '雪'.repeat(1_000) + '\n[exit code: 9]'
+      const notice = formatSpillNotice({ kind: 'exact', count: Buffer.byteLength(original, 'utf8') }, spillReference)
+      const { block, saves } = await executeShell(original, nested, name, Buffer.byteLength(notice, 'utf8'))
+      expect(block.content).toEqual([{ type: 'text', text: notice }])
+      expect(saves).toHaveLength(1)
+      expect(saves[0]!.bytes).toEqual(Buffer.from(original, 'utf8'))
+      expect(terminalCardModel(block)).toBeNull()
+      expect(isSpilledShellCall(block)).toBe(true)
+    })
+
+    it('retains a terminal card when the result fits without spilling', async () => {
+      const original = 'short output\n[exit code: 7]'
+      const { block, saves } = await executeShell(original, nested, name)
+      expect(saves).toHaveLength(0)
+      expect(block.content).toEqual([{ type: 'text', text: original }])
+      expect(terminalCardModel(block)?.card).toMatchObject({ output: 'short output', exitCode: 7 })
+      expect(isSpilledShellCall(block)).toBe(false)
+    })
+  })
+})

+ 50 - 12
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -216,10 +216,37 @@ describe('terminalCardModel', () => {
     })
   })
 
-  it('returns null without a paired call and for Code Dispatch children', () => {
+  it('returns null without a paired call', () => {
     expect(terminalCardModel(settled({ call: null }))).toBeNull()
-    expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
-    expect(terminalCardModel(running({ parentCallId: 'parent' }))).toBeNull()
+  })
+
+  it('derives the same terminal card for root calls and Code Dispatch children', () => {
+    expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toEqual(terminalCardModel(settled()))
+    expect(terminalCardModel(running({ parentCallId: 'parent' }))).toEqual(terminalCardModel(running()))
+  })
+
+  it.each(['bash', 'pwsh'])('keeps nested persistent %s running cards and settled generic results', (name) => {
+    const argsRaw = JSON.stringify({ command: 'pwd' })
+    expect(terminalCardModel(running({ name, argsRaw, parentCallId: 'parent' })))
+      .toEqual(terminalCardModel(running({ name, argsRaw })))
+    expect(terminalCardModel(settled({ call: { name, argsRaw }, parentCallId: 'parent' }))).toBeNull()
+  })
+
+  it.each(['bash', 'pwsh'])('does not infer %s exit status from a spilled preview', (name) => {
+    const notice = '(Omitted 50000 bytes. Full formatted result stored at: /spill/output.txt. Read the file.)'
+    for (const parentCallId of [undefined, 'parent']) {
+      for (const preview of ['failed\n[exit code: 7]', 'killed\n[killed by signal: SIGTERM]', 'partial', '']) {
+        expect(terminalCardModel(settled({
+          ...parentCallId === undefined ? {} : { parentCallId },
+          call: { name, argsRaw: ARGS },
+          content: [{ type: 'text', text: preview === '' ? notice : `${preview}\n\n${notice}` }],
+        }))).toBeNull()
+      }
+    }
+    expect(terminalCardModel(settled({
+      call: { name, argsRaw: ARGS },
+      content: [{ type: 'text', text: `${notice}\nordinary output` }],
+    }))).not.toBeNull()
   })
 
   it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => {
@@ -463,6 +490,18 @@ describe('BashRow terminal card', () => {
       .toBe('a.ts  b.ts\nc.ts  d.ts\n')
   })
 
+  it('expands a spilled child result without a successful terminal indicator', () => {
+    const output = 'failed\n[exit code: 7]\n\n(Omitted 50000 bytes. Full formatted result stored at: /spill/output.txt. Read the file.)'
+    const view = render(<BashRow {...rowProps(settled({
+      parentCallId: 'parent', content: [{ type: 'text', text: output }],
+    }))} />)
+    const row = view.container.querySelector('[data-sample="bash"]')!
+    expect(row.getAttribute('role')).toBe('button')
+    fireEvent.click(row)
+    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    expect(view.container.querySelectorAll('[class*="_ioText_"]')[1]?.textContent).toBe(output)
+  })
+
   it('a non-terminal bash call (background start) renders the summary row alone', () => {
     const view = render(<BashRow {...rowProps(settled({
       call: { name: 'bash', argsRaw: shellArgs({ command: 'sleep 30', description: 'Wait', run_in_background: true }) },
@@ -632,19 +671,17 @@ describe('DetailsPanel Output section', () => {
     expect(pre?.textContent).toBe('permission denied')
   })
 
-  it('a Code Dispatch child keeps the flattened form despite valid terminal raw fields', () => {
+  it('a Code Dispatch child renders its terminal output in Details', () => {
     const child = settled({ callId: 'c1', parentCallId: 'p1' })
     const view = mount(snapshot({
       runningCalls: [running({ callId: 'p1', subCalls: [child] })],
     }), target)
-    // No terminal card: the generic path renders the result text in the Output
-    // section's <pre> (the Input section has its own, hence the scoping).
-    expect(view.container.querySelector('[data-terminal]')).toBeNull()
-    const output = view.getByText('输出').closest('section')
-    expect(output?.querySelector('pre')?.textContent).toContain('a.ts  b.ts')
+    expect(view.container.querySelector('[data-terminal]')?.textContent).toContain('a.ts  b.ts')
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(runStateOf(view.container)).toBe('done')
   })
 
-  it('a running Code Dispatch child keeps the running placeholder', () => {
+  it('a running Code Dispatch child renders its terminal prompt in Details', () => {
     const view = mount(snapshot({
       // The leading non-matching sub-call exercises the scan's skip.
       runningCalls: [running({
@@ -655,8 +692,9 @@ describe('DetailsPanel Output section', () => {
         ],
       })],
     }), target)
-    expect(view.getByText('运行中…')).toBeTruthy()
-    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    expect(view.queryByText('运行中…')).toBeNull()
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(runStateOf(view.container)).toBe('ongoing')
   })
 
   it('a window-truncated call head titles the panel by callId and drops the Input section', () => {

+ 3 - 0
packages/client/ui-tool/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../spill/spill-policy"
+    },
     {
       "path": "../../attachment/attachment"
     },

+ 2 - 2
packages/spill/spill-policy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/spill/spill-policy/README.md
-README.md: 073efeaafdd2e5c94c1f2b18871c26a6eedfdaa7
-README.zh.md: b3a35b2d35bcec6646f92e508791659fe7b773cd
+README.md: 1ccf250bacd0b896e6f140393fd3447de3af1ab0
+README.zh.md: e6c537a1c9bd0c24a09063898638a12bfe84466d

+ 7 - 0
packages/spill/spill-policy/README.md

@@ -86,11 +86,17 @@ The policy is deliberately narrow: it only decides **when** to spill and compose
 
 A `tools/post-execute` waterfall listener (registered with `prepend`, delegating via `next()`) bounds the model-facing result; a `tools/ptc-dispatch-log` listener bounds the durable log copy of each `run_code` sub-call. Both share one replacement helper so the two projections are byte-identical. The post-execute arm skips `read` to avoid a read → spill → read loop; the dispatch-log arm bounds `read` sub-calls because a log copy is not model context.
 
+<a id="shared-notice-ownership"></a>
+### Shared notice ownership
+
+The browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry owns both `formatSpillNotice(omitted, ref)`, used by the producer, and `hasSpillNotice(text)`, used by presentation consumers. Formatting and recognition share the notice delimiters; omission validation uses the existing `describeOmitted` formatter rather than a second copy of its prose. Recognition accepts a complete final notice after a preview or by itself and preserves the persisted notice spelling. It reads recorded text without rewriting it.
+
 ### Source map
 
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` validation, the two waterfall listeners, the shared replacement helper |
+| [`src/notice.ts`](src/notice.ts) | Browser-safe notice formatting and recognition, published as `./notice` |
 | [`src/types.ts`](src/types.ts) | `SpillPolicyExec`: the minimal structural view of a tool execution the policy reads for the owning session id |
 | — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. |
 
@@ -138,6 +144,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 These limits define when the policy cannot help. They are current package constraints.
 
+- **Text recognition cannot authenticate output** — a tool can print the same notice text; `hasSpillNotice` identifies a text convention, not proof that the policy saved a result.
 - **Only final plain-text results are spillable** — mixed-content results, blocked feedback, and `read` pass through; provider truncation or tool-owned retention that happened earlier cannot be recovered here.
 - **A notice that cannot fit disables replacement for that call** — a tiny cap or long locator leaves the oversized original inline after the backend has already saved an unreferenced spill.
 

+ 7 - 0
packages/spill/spill-policy/README.zh.md

@@ -86,11 +86,17 @@ kind: "package-reference"
 
 `tools/post-execute` waterfall(瀑布式事件)监听器(以 `prepend` 注册、通过 `next()` 委托)约束面向模型的结果;`tools/ptc-dispatch-log` 监听器约束每个 `run_code` 子调用的持久日志副本。两者共享同一个替换辅助函数,因此两个投影字节一致。post-execute 分支跳过 `read` 以避免 read → spill → read 循环;dispatch-log 分支约束 `read` 子调用,因为日志副本不是模型上下文。
 
+<a id="shared-notice-ownership"></a>
+### 共享通知的所有权
+
+浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 同时负责生产方使用的 `formatSpillNotice(omitted, ref)` 和展示消费方使用的 `hasSpillNotice(text)`。格式化与识别共用通知分隔符;省略信息通过现有的 `describeOmitted` 格式化函数校验,而非复制一套文案。识别支持预览之后或单独出现的完整末尾通知,并保留持久化通知的原有拼写。它只读取已记录的文本,不改写文本。
+
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 插件入口:`Config` 校验、两个 waterfall 监听器、共享替换辅助函数 |
+| [`src/notice.ts`](src/notice.ts) | 浏览器安全的通知格式化与识别,以 `./notice` 发布 |
 | [`src/types.ts`](src/types.ts) | `SpillPolicyExec`:策略读取所属会话 id 所需的最小结构化工具执行视图 |
 | — | 不发布运行时不变式伴生入口;约定在 seam 处强制执行。 |
 
@@ -138,6 +144,7 @@ kind: "package-reference"
 
 这些限制说明策略在哪些情况下无法提供帮助。它们是当前的包约束。
 
+- **文本识别无法认证输出来源**——工具也能打印相同的通知文本;`hasSpillNotice` 识别的是文本约定,不能证明策略保存过结果。
 - **只能对最终纯文本结果执行 spill**——混合内容结果、阻止反馈与 `read` 会原样通过;此前已经发生的提供方截断或工具自有保留无法在此恢复。
 - **通知无法容纳时会禁用该次调用的替换**——上限极小或定位信息很长时,后端已经保存了无引用的 spill,但过大的原始结果仍留在内联位置。
 

+ 5 - 0
packages/spill/spill-policy/package.json

@@ -18,11 +18,16 @@
       "types": "./lib/types/index.d.ts",
       "default": "./lib/index.js"
     },
+    "./notice": {
+      "types": "./lib/types/notice.d.ts",
+      "default": "./lib/types/notice.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
+    "lib/types/**/*.js",
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",

+ 4 - 9
packages/spill/spill-policy/src/index.ts

@@ -46,13 +46,14 @@
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-output-retention'
+import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
 import type { Omitted } from '@deepseek-ai/dsh-output-retention'
 import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
 import type { SpillPolicyExec } from './types.ts'
+import { formatSpillNotice } from './notice.ts'
 
 export type { SpillPolicyExec } from './types.ts'
 
@@ -101,12 +102,6 @@ function preview(text: string, budget: number): { text: string; omitted: Omitted
   return { text: kept.text, omitted: kept.omittedBytes }
 }
 
-/** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */
-function spillNotice(omitted: Omitted, ref: SpillRef): string {
-  const omission = describeOmitted(omitted, 'bytes')
-  return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})`
-}
-
 export function apply(ctx: Context, config: Config): void {
   const maxInlineBytes = config.maxInlineBytes
   // Omitted ⇒ no automatic spill policy: register nothing at all.
@@ -168,10 +163,10 @@ export function apply(ctx: Context, config: Config): void {
     // count (the full byte total): its digit count bounds the real count's, so
     // the reserved size is a safe upper bound and the final notice is never
     // longer than what we reserved. `\n\n` is the 2-byte join.
-    const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
+    const reserve = Buffer.byteLength(formatSpillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
     const previewBudget = Math.max(0, cap - reserve)
     const { text: previewText, omitted } = preview(text, previewBudget)
-    const notice = spillNotice(omitted, ref)
+    const notice = formatSpillNotice(omitted, ref)
     const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
     // Invariant: the policy NEVER emits a replacement larger than the cap. When
     // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),

+ 52 - 0
packages/spill/spill-policy/src/notice.ts

@@ -0,0 +1,52 @@
+/** Browser-safe formatting and recognition of persisted spill-policy notices. */
+import { describeOmitted, type Omitted } from '@deepseek-ai/dsh-output-retention'
+import type { SpillRef } from '@deepseek-ai/dsh-spill'
+
+const OPEN = '('
+const CLOSE = ')'
+const LOCATION = ' Full formatted result stored at: '
+const GUIDANCE_SEPARATOR = '. '
+const SEPARATOR = '\n\n'
+const EXACT_OMISSION = describeOmitted({ kind: 'exact', count: 0 }, 'bytes')
+const COUNT_OFFSET = EXACT_OMISSION.indexOf('0')
+const COUNT_SUFFIX = EXACT_OMISSION.slice(COUNT_OFFSET + 1)
+
+/**
+ * Format the notice appended to a retained preview, preserving its persisted spelling.
+ * @param omitted - bytes omitted by the retention policy.
+ * @param ref - saved text locator and retrieval guidance.
+ * @returns the complete notice without a leading preview separator.
+ */
+export function formatSpillNotice(omitted: Omitted, ref: Pick<SpillRef, 'locator' | 'retrievalHint'>): string {
+  return `${OPEN}${describeOmitted(omitted, 'bytes')}${LOCATION}${ref.locator}${GUIDANCE_SEPARATOR}${ref.retrievalHint}${CLOSE}`
+}
+
+function isOmission(text: string): boolean {
+  if (text === describeOmitted({ kind: 'none' }, 'bytes')
+    || text === describeOmitted({ kind: 'unknown' }, 'bytes')) return true
+  const count = Number(text.slice(COUNT_OFFSET, text.length - COUNT_SUFFIX.length))
+  return Number.isSafeInteger(count) && count >= 0
+    && text === describeOmitted({ kind: 'exact', count }, 'bytes')
+}
+
+/**
+ * Recognize a final spill-policy notice in persisted text, including notice-only output.
+ * This identifies the text convention, not authenticated provenance of tool output.
+ * @param text - complete recorded text result.
+ * @returns whether a complete notice occupies the end of the result.
+ */
+export function hasSpillNotice(text: string): boolean {
+  if (!text.endsWith(CLOSE)) return false
+  let start = 0
+  while (true) {
+    const next = text.indexOf(`${SEPARATOR}${OPEN}`, start)
+    const candidate = text.slice(start, next < 0 ? -CLOSE.length : next)
+    const location = candidate.indexOf(LOCATION, OPEN.length)
+    if (candidate.startsWith(OPEN) && location >= 0
+      && isOmission(candidate.slice(OPEN.length, location))) {
+      return text.indexOf(GUIDANCE_SEPARATOR, start + location + LOCATION.length) >= 0
+    }
+    if (next < 0) return false
+    start = next + SEPARATOR.length
+  }
+}

+ 47 - 0
packages/spill/spill-policy/tests/notice.spec.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { SpillLocator } from '@deepseek-ai/dsh-spill'
+import { formatSpillNotice, hasSpillNotice } from '../src/notice.ts'
+
+const ref = { locator: SpillLocator('/spill/output.txt'), retrievalHint: 'Read the file.' }
+
+describe('persisted spill notice', () => {
+  it('preserves the historical spelling independently of the formatter', () => {
+    const historical = '(Omitted 50000 bytes. Full formatted result stored at: /spill/output.txt. Read the file.)'
+    expect(formatSpillNotice({ kind: 'exact', count: 50000 }, ref)).toBe(historical)
+    expect(hasSpillNotice(historical)).toBe(true)
+    expect(hasSpillNotice(`failed\n[exit code: 7]\n\n${historical}`)).toBe(true)
+  })
+
+  it('recognizes every omission form the producer can format', () => {
+    for (const omitted of [{ kind: 'none' }, { kind: 'unknown' }, { kind: 'exact', count: 0 }] as const) {
+      const notice = formatSpillNotice(omitted, ref)
+      expect(hasSpillNotice(notice)).toBe(true)
+      expect(hasSpillNotice(`preview\n\n${notice}`)).toBe(true)
+    }
+  })
+
+  it('keeps locator and retrieval text opaque, including parentheses and newlines', () => {
+    const notice = formatSpillNotice({ kind: 'exact', count: 42 }, {
+      locator: SpillLocator('/spill/报告 (1).txt'), retrievalHint: 'Read it.\n\n(additional guidance)',
+    })
+    expect(hasSpillNotice(notice)).toBe(true)
+    expect(hasSpillNotice(`preview\n\n${notice}`)).toBe(true)
+  })
+
+  it('scans repeated non-notice prefixes without losing the final notice', () => {
+    const prefix = '\n\n(ordinary output'.repeat(20000)
+    expect(hasSpillNotice(prefix + ')')).toBe(false)
+    const notice = formatSpillNotice({ kind: 'exact', count: 42 }, ref)
+    expect(hasSpillNotice(prefix + '\n\n' + notice)).toBe(true)
+  })
+
+  it('rejects ordinary, partial, non-final, and malformed notice text', () => {
+    const notice = formatSpillNotice({ kind: 'exact', count: 42 }, ref)
+    for (const text of [
+      '', 'ordinary output)', '(ordinary output)', '\n\n(ordinary output)', 'prefix' + notice,
+      notice.slice(0, -1), notice + '\nother output',
+      notice.replace('42', '-1'), notice.replace('42', '1.5'), notice.replace('42', '0042'),
+      notice.replace('Omitted', 'Ignored'), notice.replace('. Read the file.', ''),
+    ]) expect(hasSpillNotice(text)).toBe(false)
+  })
+})

+ 24 - 0
pnpm-lock.yaml

@@ -3519,6 +3519,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
@@ -3555,6 +3558,27 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-code-runtime-worker-thread':
+        specifier: workspace:^
+        version: link:../../code-runtime/code-runtime-worker-thread
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-spill':
+        specifier: workspace:^
+        version: link:../../spill/spill
+      '@deepseek-ai/dsh-spill-policy':
+        specifier: workspace:^
+        version: link:../../spill/spill-policy
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path

+ 7 - 0
scripts/client-bundle-purity.spec.ts

@@ -101,6 +101,13 @@ describe('client bundle purity gate', () => {
     expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
   })
 
+  it('admits only the pure spill notice entry, not its Host policy', () => {
+    expect(resolveId('@deepseek-ai/dsh-spill-policy/notice')).toBeNull()
+    expect(resolveId('@deepseek-ai/dsh-output-retention')).toBeNull()
+    expect(() => resolveId('@deepseek-ai/dsh-spill-policy')).toThrow(/purity/)
+    expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
+  })
+
   it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
     expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
     expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)

+ 8 - 2
snapshots/web/ptc-round/ui.expected.md

@@ -31,8 +31,14 @@
   - img
   - img
   - text: Code Run bash echo and catch missing file read
-- img
-- text: Bash Echo CODE_ROUND_OK Failed
+- button "Bash Echo CODE_ROUND_OK" [expanded]:
+  - img
+  - text: Bash Echo CODE_ROUND_OK
+- text: Done workspace echo CODE_ROUND_OK
+- button "Copy"
+- text: CODE_ROUND_OK
+- button "Inspect"
+- text: Failed
 - 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
   - img
   - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"

+ 1 - 0
tsconfig.base.json

@@ -40,6 +40,7 @@
       "@deepseek-ai/node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"],
       "@deepseek-ai/dsh-invariants": ["./packages/runtime-diagnostics/invariants/src/index.ts"],
       "@deepseek-ai/dsh-tool-call-timeout-policy": ["./packages/guard/timeout-policy/src"],
+      "@deepseek-ai/dsh-spill-policy/notice": ["./packages/spill/spill-policy/src/notice.ts"],
       "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"],
       "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"],
       "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"],

+ 2 - 0
tsconfig.client.json

@@ -38,6 +38,8 @@
     "packages/client/*/tests/**/*.host.spec.ts"
   ],
   "references": [
+    // Producer-to-UI spill tests execute the real nested dispatch worker.
+    { "path": "./packages/code-runtime/code-runtime-worker-thread" },
     // Shared leaf: web e2e boots the real host webserver (fixture + real-host
     // smoke policy). webserver has zero workspace deps and no cordis merge,
     // so it cannot drag host-side Context augmentation into this program.