1
0
Эх сурвалжийг харах

Merge latest master into thin Electron Web UI branch

07akioni 1 долоо хоног өмнө
parent
commit
695720ea8d
72 өөрчлөгдсөн 1187 нэмэгдсэн , 377 устгасан
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
  3. 4 4
      .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml
  5. 4 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
  6. 4 0
      .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml
  8. 29 0
      .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md
  9. 29 0
      .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md
  10. 2 2
      apps/cli/README.i18n.yaml
  11. 4 2
      apps/cli/README.md
  12. 4 2
      apps/cli/README.zh.md
  13. 404 0
      apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
  14. 8 15
      apps/web/tests/lifecycle-chrome.e2e.ts
  15. 2 2
      docs/config-catalog.i18n.yaml
  16. 1 1
      docs/config-catalog.md
  17. 1 1
      docs/config-catalog.zh.md
  18. 2 2
      docs/subsystems/system-prompt.i18n.yaml
  19. 3 1
      docs/subsystems/system-prompt.md
  20. 3 1
      docs/subsystems/system-prompt.zh.md
  21. 2 2
      packages/boot/app-boot/README.i18n.yaml
  22. 21 14
      packages/boot/app-boot/README.md
  23. 21 14
      packages/boot/app-boot/README.zh.md
  24. 2 2
      packages/client/modules/README.i18n.yaml
  25. 2 0
      packages/client/modules/README.md
  26. 2 0
      packages/client/modules/README.zh.md
  27. 2 2
      packages/client/modules/src/index.ts
  28. 49 9
      packages/client/modules/tests/node-half.client.spec.ts
  29. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  30. 0 0
      packages/client/ui-primitives/README.md
  31. 0 0
      packages/client/ui-primitives/README.zh.md
  32. 42 26
      packages/client/ui-primitives/src/ConnectionIndicator.module.css
  33. 51 43
      packages/client/ui-primitives/src/ConnectionIndicator.tsx
  34. 25 4
      packages/client/ui-primitives/tests/atoms.client.spec.tsx
  35. 2 2
      packages/client/ui-settings-general/README.i18n.yaml
  36. 2 2
      packages/client/ui-settings-general/README.md
  37. 2 2
      packages/client/ui-settings-general/README.zh.md
  38. 32 4
      packages/client/ui-settings-general/src/client/SettingsRoot.tsx
  39. 4 6
      packages/client/ui-settings-general/src/client/locales.ts
  40. 2 2
      packages/client/ui-settings-general/tests/apply.client.spec.ts
  41. 44 1
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
  42. 2 2
      packages/core/system-prompt/README.i18n.yaml
  43. 4 2
      packages/core/system-prompt/README.md
  44. 4 2
      packages/core/system-prompt/README.zh.md
  45. 9 3
      packages/core/system-prompt/src/index.ts
  46. 19 0
      packages/core/system-prompt/tests/system-prompt.spec.ts
  47. 2 2
      packages/core/tools/README.i18n.yaml
  48. 1 1
      packages/core/tools/README.md
  49. 1 1
      packages/core/tools/README.zh.md
  50. 4 3
      packages/core/tools/src/index.ts
  51. 20 0
      packages/core/tools/tests/fixtures/literal-sdk.ts
  52. 33 1
      packages/core/tools/tests/ptc.spec.ts
  53. 65 8
      packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts
  54. 0 148
      packages/experimental/code-runtime-python/tests/runtime.spec.ts
  55. 31 0
      packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts
  56. 2 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  57. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  58. 1 1
      packages/subprocess/subprocess-local/README.md
  59. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  60. 4 1
      packages/subprocess/subprocess-local/src/linux-scope.ts
  61. 76 0
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  62. 3 3
      packages/terminal/terminal-bash/tests/local.spec.ts
  63. 9 3
      scripts/browser-bundled-externals.spec.ts
  64. 4 2
      scripts/browser-bundled-externals.ts
  65. 25 0
      scripts/run-gates.spec.ts
  66. 16 10
      scripts/run-gates.ts
  67. 4 0
      snapshots/session/ptc-turn/cordis.snapshot.yml
  68. 4 0
      snapshots/session/ptc-turn/cordis.yml
  69. 5 0
      snapshots/session/ptc-turn/system-prompt.expected.md
  70. 4 0
      snapshots/session/ptc-workspace-context/cordis.snapshot.yml
  71. 4 0
      snapshots/session/ptc-workspace-context/cordis.yml
  72. 1 1
      snapshots/web/lifecycle-chrome/connection-error.expected.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
-2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 35bb7c6fabc85ae6f93bdbb67e13910eea627ca3
-2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 53d69cf45c02f6534334561b626d2c2ae6087c05
+2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 8f5542cb09930e62fd1e26960166fbdd6d3f6745
+2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 348ae36cf6549d412adab1d3cdb5cc03f0badd01

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

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

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

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

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md
-2026-09-09-consumer-owned-startup-strictness.md: 8e3d5f0141245ba2fa2c85faba607ac1be952e37
-2026-09-09-consumer-owned-startup-strictness.zh.md: 3873b9ceeb6204939e817a53514f5e18767ececc
+2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2
+2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4

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

@@ -33,3 +33,7 @@ Stable required entry ids are part of application assembly. Renaming one require
 ## Testing
 
 App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
+
+The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together.
+
+The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing.

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

@@ -33,3 +33,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
 ## 测试
 
 App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
+
+[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
+
+矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md
+2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b
+2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5

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

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

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

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

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

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

+ 4 - 2
apps/cli/README.md

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

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

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

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

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

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

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

+ 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: 3e1e0f4cd22c9a9775b0c12e162e1477eb277f9d
-config-catalog.zh.md: 0b6a2b7a91f3030e5dce7d00a9d5178e096932bb
+config-catalog.md: 1f47e861ec13894263a7fb667411a566d210a92c
+config-catalog.zh.md: 46d6b451944aae40a901962144b133f254f6c04f

+ 1 - 1
docs/config-catalog.md

@@ -2611,7 +2611,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 

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

@@ -2613,7 +2613,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
+来源:[`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 

+ 2 - 2
docs/subsystems/system-prompt.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md
-system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753
-system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e
+system-prompt.md: 898520e8fc88a9fafd8429723fc48bad3b4ba210
+system-prompt.zh.md: 82e557a309a48f546d7c7fa5a8c1097152b4d145

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

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

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

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

+ 2 - 2
packages/boot/app-boot/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/boot/app-boot/README.md
-README.md: 69e890371bfdcbeb894e5a804581cd4cb7db377f
-README.zh.md: c8a750ef2295a602d5ed1842cc9f94fb0c275737
+README.md: dc6ca3ad7168252ae3e202330283e943d9aef870
+README.zh.md: 66932227c1035bb545a4a67ddc6b36a3feaff9b4

+ 21 - 14
packages/boot/app-boot/README.md

@@ -56,7 +56,7 @@ Your machine-local preferences also live in the Harness home:
 - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
 - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
 
-Profiles with `patchReload: live` watch both user patch files. Parse failures preserve the running configuration; plugin activation failures are reported and can leave a partially applied tree. A later valid edit can recover it. Loader changes are not rolled back. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
+Profiles with `patchReload: live` watch both user patch files and apply the [reload failure policy](#startup-and-reload-failures). A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
@@ -64,21 +64,28 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
 
 Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
 
-### What you see when startup fails
+<a id="startup-and-reload-failures"></a>
+### Startup and reload failures
 
-After the Loader settles, app-boot classifies each enabled entry by stable id. Optional failures produce one warning and leave active siblings running. Required failures produce the same entry detail, then dispose the application and reject startup.
+After the Loader settles, app-boot reports optional failures as warnings and rejects startup if an enabled required entry cannot activate. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
 
-| Failure pattern | Entry result | Startup action |
-|---|---|---|
-| The root YAML cannot be read or parsed, or is not an entry list | Bootstrap Include fails | Reject and dispose; no partial application is accepted |
-| A plugin module cannot be imported | Entry has no fiber | Warn if optional; reject and dispose if required |
-| An entry's `disabled: !!js` expression throws | Entry cannot determine its disabled state; report the evaluation error | Warn if optional; reject and dispose if required |
-| Config expression evaluation or the plugin's config schema fails during activation | Fiber is `FAILED` with the validation error | Warn if optional; reject and dispose if required |
-| Synchronous `apply()` throws | Fiber is `FAILED` with the thrown error | Warn if optional; reject and dispose if required |
-| Asynchronous `apply()` throws | Fiber is `FAILED` with the thrown error | Warn if optional; reject and dispose if required |
-| Required injected services never appear | Fiber remains `PENDING` and names the missing services | Warn if optional; reject and dispose if required |
+| Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR |
+|---|---|---|---|
+| Root config or required overlay is missing, unreadable, malformed, or contains invalid entries | Stop startup | Stop startup | Malformed or invalid live patches are rejected without changing the running configuration; a valid edit applies |
+| Module import fails or module evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a corrected import can activate |
+| Plugin config schema validation fails | Warn; continue | Stop startup | A new entry stays inactive; an existing entry retains its prior instance and config; a valid correction applies |
+| Config `!!js` evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a valid correction can activate |
+| `disabled: !!js` evaluation throws | Warn; continue | Stop startup | Report the evaluation error rather than treating the entry as disabled; a valid correction can activate |
+| Synchronous `apply()` throws | Warn; continue | Stop startup | Report the error; keep successful siblings; corrected config can activate |
+| Asynchronous `apply()` throws | Warn after settlement; continue | Stop startup after settlement | Report the error after settlement; keep successful siblings; corrected config can activate |
+| An injected service is unavailable | Warn; continue while the entry waits for its dependencies | Stop startup | Keep the entry waiting; adding the missing provider can activate it |
+| HTTP port binding fails | Warn; continue without that endpoint | Stop startup | Keep the process running without the failed endpoint; corrected config can restore it |
+| Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id |
+| Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit |
 
-App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. Unrelated unhandled rejections remain fatal. Later config HMR reports failures without repeating the required-startup policy or restoring previous plugin config; a valid edit can recover the failed entry.
+The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes.
+
+The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) and [startup acceptance](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) verify these outcomes through the shipped Web profile; [app-boot tests](tests/app-boot.spec.ts) also exercise root Include failures.
 
 If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it.
 
@@ -100,7 +107,7 @@ This section explains how the outcomes above are realized and points at the code
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup.
+- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. Application-owned filesystem profiles use the same projection for their installation closure, so missing peers resolve inside their own profile without maintaining a shared fallback directory.
 
 - **Package-operation cleanup.** The shared fallback owner removes only the profile links it created before a package-manager operation. pnpm-managed entries remain untouched, and the profile runner recreates required installation and bundle links at the next boot. Desktop uses this owner without a separate runtime-state or lockfile-hash reconciliation scheme.

+ 21 - 14
packages/boot/app-boot/README.zh.md

@@ -56,7 +56,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
-带 `patchReload: live` 的 profile 会监视两份用户 patch 文件。解析失败会保留运行中的配置;插件激活失败会被报告,并可能留下部分应用的配置树。后续有效编辑可以恢复它。Loader 更改不会回滚。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
+带 `patchReload: live` 的 profile 会监视两份用户 patch 文件,并应用[重载失败策略](#startup-and-reload-failures)。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
@@ -64,21 +64,28 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
 
-### 启动失败时你会看到什么
+<a id="startup-and-reload-failures"></a>
+### 启动与重载失败
 
-Loader 结算后,app-boot 按稳定 id 对每个已启用 entry 分类。Optional failure 输出一次警告,并让 active sibling 继续运行。Required failure 输出相同的 entry 详情,然后拆卸应用并拒绝启动
+Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的 required 条目无法激活,则拒绝启动。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新
 
-| 失败模式 | Entry 结果 | 启动措施 |
-|---|---|---|
-| 根 YAML 无法读取或解析,或不是 entry list | Bootstrap Include 失败 | 拒绝并拆卸;不接受部分应用 |
-| Plugin module 无法 import | Entry 没有 fiber | Optional 时警告;required 时拒绝并拆卸 |
-| Entry 的 `disabled: !!js` 表达式抛出异常 | Entry 无法确定禁用状态;报告求值错误 | Optional 时警告;required 时拒绝并拆卸 |
-| Config expression 求值或 plugin config schema 在 activation 时失败 | Fiber 为 `FAILED`,保留校验错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 同步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 异步 `apply()` throw | Fiber 为 `FAILED`,保留抛出的错误 | Optional 时警告;required 时拒绝并拆卸 |
-| 必需的 injected service 始终未出现 | Fiber 保持 `PENDING`,并指出缺失 service | Optional 时警告;required 时拒绝并拆卸 |
+| 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR |
+|---|---|---|---|
+| 根配置或必需 overlay 缺失、不可读、格式错误,或包含无效条目 | 终止启动 | 终止启动 | 拒绝格式错误或无效的实时 patch,不改变运行中的配置;有效修改可以应用 |
+| 模块 import 失败或模块求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正 import 后可以激活 |
+| 插件配置 schema 校验失败 | 警告;继续 | 终止启动 | 新条目保持未激活;现有条目保留原实例与配置;有效修正可以应用 |
+| 配置 `!!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;有效修正后可以激活 |
+| `disabled: !!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告求值错误,不将条目当作已禁用;有效修正后可以激活 |
+| 同步 `apply()` throw | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正配置后可以激活 |
+| 异步 `apply()` throw | 结算后警告;继续 | 结算后终止启动 | 结算后报告错误;保留成功的兄弟插件;修正配置后可以激活 |
+| 注入的服务不可用 | 警告;继续,条目等待依赖 | 终止启动 | 条目继续等待;补上缺失的提供方后可以激活 |
+| HTTP 端口绑定失败 | 警告;继续,但该端点不可用 | 终止启动 | 进程继续运行,但失败的端点不可用;修正配置后可以恢复 |
+| 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
+| 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 |
 
-App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。无关的未处理 rejection 仍然致命。之后的 config HMR 会报告失败,但不会再次应用 required 启动策略,也不会恢复旧 plugin config;有效修改可以恢复失败的 entry。
+上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。
+
+[Web 进程矩阵](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)和[启动验收测试](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)通过随附 Web profile 验证这些结果;[app-boot 测试](tests/app-boot.spec.ts)还覆盖根 Include 失败。
 
 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。
 
@@ -100,7 +107,7 @@ App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。
+- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。应用自有的文件系统 profile 使用同一投影机制补全安装闭包,因此缺失的 peer 可在自己的 profile 内解析,无需维护共享后备目录。
 
 - **包操作清理。** 共享模块补全逻辑在包管理器操作前仅移除自己创建的 profile 链接。pnpm 管理的条目保持不变,profile runner 在下次启动时重新创建所需的安装包及 bundle 链接。Desktop 使用这一归属机制,不维护独立的运行时状态或锁文件哈希协调流程。

+ 2 - 2
packages/client/modules/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/modules/README.md
-README.md: 83ba5357cf84a12105518ed057fd120584b636cb
-README.zh.md: 9e5c75838e52fda7d74b074b9c1a9dacef7932a8
+README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f
+README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c

+ 2 - 0
packages/client/modules/README.md

@@ -71,6 +71,8 @@ The Node half snapshots each client bundle and available source map before publi
 
 ### Boot manifest injection
 
+The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server.
+
 The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode.
 
 ### Source map

+ 2 - 0
packages/client/modules/README.zh.md

@@ -71,6 +71,8 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map
 
 ### 启动 manifest 注入
 
+bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。
+
 宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。
 
 ### 源码索引

+ 2 - 2
packages/client/modules/src/index.ts

@@ -504,6 +504,7 @@ export class ClientModuleRegistry extends Service {
 
   /**
    * Build the service: subscribe, seed, and run the activation flush.
+   * Bundle routes follow the optional Web carrier's injected lifecycle.
    * @param ctx - plugin context carrying Loader and an optional Web carrier.
    */
   constructor(ctx: Context) {
@@ -540,8 +541,7 @@ export class ClientModuleRegistry extends Service {
         'client-modules: bundle route',
       )
     }
-    if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier)
-    else registerWebCarrier(ctx)
+    ctx.inject(['webServer'], registerWebCarrier)
     ctx.on('webserver/index-inject', (table) => {
       table.push(...bootInjections(this.composed))
     })

+ 49 - 9
packages/client/modules/tests/node-half.client.spec.ts

@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { runInNewContext } from 'node:vm'
-import { Context, type Fiber } from '@deepseek-ai/cordis'
+import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
 import * as modulesClient from '../src/client/index.ts'
@@ -24,12 +24,50 @@ const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
 const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
 
 let root: string | undefined
+const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
 
-afterEach(() => {
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
+    await ready
+    await ctx.fiber.dispose()
+  }))
   if (root !== undefined) rmSync(root, { recursive: true, force: true })
   root = undefined
 })
 
+it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
+  const ctx = new Context()
+  contexts.push({ ctx })
+  ctx.provide('loader', { entries: () => [] })
+  const routes = new Set<WebRoute>()
+  const mountServer = () => ctx.plugin((serverCtx) => {
+    serverCtx.provide('webServer', {
+      register: (route: WebRoute) => {
+        routes.add(route)
+        return () => { routes.delete(route) }
+      },
+    } as WebServer)
+  })
+  let server = serverFirst ? await mountServer() : undefined
+  const modules = await ctx.plugin(ClientModuleRegistry)
+  const service = ctx.get('clientModules')!
+  expect(service.graph().entries).toEqual([])
+  expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
+  if (!serverFirst) {
+    expect(routes.size).toBe(0)
+    server = await mountServer()
+  }
+  await expect.poll(() => routes.size).toBe(1)
+  await server!.dispose()
+  await expect.poll(() => routes.size).toBe(0)
+  expect(modules.state).toBe(FiberState.ACTIVE)
+  expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
+  await mountServer()
+  await expect.poll(() => routes.size).toBe(1)
+  await modules.dispose()
+  expect(routes.size).toBe(0)
+})
+
 /** Create a resolvable package whose client export points at the returned path. */
 function writePackage(
   packageName: string,
@@ -65,8 +103,10 @@ function constructWithRoute(
     entryBaseUrl?: string
     internal?: NonNullable<Context['loader']['internal']>
   } = {},
-): { context: Context; service: ClientModuleRegistry; route: WebRoute } {
+): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
   const ctx = new Context()
+  const owned: typeof contexts[number] = { ctx }
+  contexts.push(owned)
   ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
   ctx.provide('loader', {
     internal: options.internal,
@@ -81,19 +121,19 @@ function constructWithRoute(
       }
     },
   })
-  let route: WebRoute | undefined
+  const route = Promise.withResolvers<WebRoute>()
   const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
     port: 0,
     register: (candidate) => {
-      if (candidate.path === '/plugins') route = candidate
+      if (candidate.path === '/plugins') route.resolve(candidate)
       return () => {}
     },
     tapIndex: () => () => {},
   }
   ctx.provide('webServer', webServer as WebServer)
   const service = new ClientModuleRegistry(ctx)
-  if (route === undefined) throw new Error('client bundle route was not registered')
-  return { context: ctx, service, route }
+  owned.ready = route.promise
+  return { context: ctx, service, route: route.promise }
 }
 
 /** Construct the node-half service over the enabled fixture entries. */
@@ -102,7 +142,7 @@ function construct(packageNames: string[]): ClientModuleRegistry {
 }
 
 /** Invoke the registered plugin route and capture status, headers, and bytes. */
-async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
+async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
   status: number
   headers: Record<string, string> | undefined
   body: Buffer
@@ -121,7 +161,7 @@ async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promi
       return response
     },
   } as unknown as ServerResponse
-  await route.handler({ method, url } as IncomingMessage, response)
+  await (await route).handler({ method, url } as IncomingMessage, response)
   return { status, headers, body }
 }
 

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: 1d3e6c6d0774ed78bb0a1952ba57e5f607f24b47
-README.zh.md: e7f0419939076b1276586cfba28129b7df1ba355
+README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e
+README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
packages/client/ui-primitives/README.md


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
packages/client/ui-primitives/README.zh.md


+ 42 - 26
packages/client/ui-primitives/src/ConnectionIndicator.module.css

@@ -4,25 +4,49 @@
   grid-template-columns: 14px max-content;
   align-items: center;
   column-gap: 4px;
-  height: 32px;
-  padding: 0 10px;
+  height: 28px;
+  padding: 0 8px;
   box-sizing: border-box;
-  border: none;
-  border-radius: 8px;
+  border: 1px solid transparent;
+  border-radius: 13px;
   font-family: inherit;
   font-size: 12px;
   font-weight: 500;
   line-height: 18px;
   white-space: nowrap;
-  transition: background-color 160ms ease-out, color 160ms ease-out;
+  transition:
+    background-color 160ms ease-out,
+    color 160ms ease-out,
+    border-color 160ms ease-out,
+    opacity 150ms ease-out;
+  animation: indicator-enter 150ms ease-out;
+}
+
+.leaving {
+  opacity: 0;
+}
+
+@keyframes indicator-enter {
+  from {
+    opacity: 0;
+  }
 }
 
 .warning {
   background: var(--dsw-alias-state-warn-tertiary);
   color: var(--dsw-alias-state-warn-label);
+  border-color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 20%, transparent);
   cursor: pointer;
 }
 
+.warning:hover {
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-state-warn-tertiary),
+    var(--dsw-alias-state-warn-primary) 6%
+  );
+}
+
 .warning:active {
   background: color-mix(
     in srgb,
@@ -39,6 +63,7 @@
 .success {
   background: var(--dsw-alias-state-success-tertiary);
   color: var(--dsw-alias-state-success-primary);
+  border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent);
 }
 
 .icon {
@@ -49,35 +74,20 @@
 }
 
 .label {
-  display: grid;
   text-align: left;
 }
 
-.stateLabel,
-.hoverLabel,
-.sizeLabel {
-  grid-area: 1 / 1;
-}
-
-.sizeLabel {
-  visibility: hidden;
-}
-
-.warning:is(:hover, :focus-visible) .stateLabel {
-  visibility: hidden;
-}
-
-.hoverLabel {
-  visibility: hidden;
+.spinner {
+  animation: spinner-rotate 0.9s linear infinite;
 }
 
-.warning:is(:hover, :focus-visible) .hoverLabel {
-  visibility: visible;
+@keyframes spinner-rotate {
+  to { transform: rotate(360deg); }
 }
 
 .dots {
   display: inline-block;
-  width: 1.5em;
+  width: 1em;
   text-align: left;
 }
 
@@ -100,8 +110,14 @@
 }
 
 @media (prefers-reduced-motion: reduce) {
+  .indicator,
   .secondDot,
-  .thirdDot {
+  .thirdDot,
+  .spinner {
     animation: none;
   }
+
+  .indicator {
+    transition: none;
+  }
 }

+ 51 - 43
packages/client/ui-primitives/src/ConnectionIndicator.tsx

@@ -1,4 +1,5 @@
-import { IconCheckOutline16, IconWarningOutline16 } from './icons/index.tsx'
+import { useEffect, useState } from 'react'
+import { IconCheckOutline16, IconLoadingOutline16, IconRefreshOutline14 } from './icons/index.tsx'
 import css from './ConnectionIndicator.module.css'
 
 /** Visual state rendered by {@link ConnectionIndicator}. */
@@ -7,11 +8,16 @@ export type ConnectionIndicatorState =
   | 'connecting'
   | 'recovered'
 
+/** Exit-transition length; keep equal to the `.leaving` transition duration in the stylesheet. */
+const EXIT_MS = 150
+
 /**
- * Render an inline connection-recovery control.
+ * Render an inline connection-recovery control. The outage and retry-attempt
+ * states are one button whose static label already names the retry action;
+ * clicking it requests an immediate reconnect. The indicator animates in on
+ * appearance and fades out for {@link EXIT_MS} before unmounting.
  * @param props.state - visible outage, retry-attempt, or recovered state.
- * @param props.disconnectedLabel - localized outage text.
- * @param props.reconnectLabel - localized action text shown on hover or focus.
+ * @param props.disconnectedLabel - localized outage text naming the retry action.
  * @param props.connectingLabel - localized retry text followed by the attempt dots.
  * @param props.recoveredLabel - localized recovery confirmation.
  * @param props.reconnectActionLabel - accessible label for the outage action.
@@ -22,7 +28,6 @@ export type ConnectionIndicatorState =
 export function ConnectionIndicator({
   state,
   disconnectedLabel,
-  reconnectLabel,
   connectingLabel,
   recoveredLabel,
   reconnectActionLabel,
@@ -31,63 +36,66 @@ export function ConnectionIndicator({
 }: {
   state: ConnectionIndicatorState | undefined
   disconnectedLabel: string
-  reconnectLabel: string
   connectingLabel: string
   recoveredLabel: string
   reconnectActionLabel: string
   restartActionLabel: string
   onReconnect: () => void
 }) {
-  if (state === undefined) return null
-  const sizeLabels = (
-    <>
-      <span className={css.sizeLabel} aria-hidden="true">{disconnectedLabel}</span>
-      <span className={css.sizeLabel} aria-hidden="true">{reconnectLabel}</span>
-      <span className={css.sizeLabel} aria-hidden="true">
-        {connectingLabel}<span className={css.dots}>...</span>
-      </span>
-      <span className={css.sizeLabel} aria-hidden="true">{recoveredLabel}</span>
-    </>
-  )
-  if (state === 'recovered') {
+  const [rendered, setRendered] = useState(state)
+  const leaving = state === undefined && rendered !== undefined
+  useEffect(() => {
+    if (state !== undefined) {
+      setRendered(state)
+      return
+    }
+    if (rendered === undefined) return
+    const timeout = window.setTimeout(() => { setRendered(undefined) }, EXIT_MS)
+    return () => { window.clearTimeout(timeout) }
+  }, [state, rendered])
+
+  if (rendered === undefined) return null
+  const leavingClass = leaving ? ` ${css.leaving}` : ''
+  if (rendered === 'recovered') {
     return (
-      <div className={`${css.indicator} ${css.success}`} role="status" aria-label={recoveredLabel}>
+      <div
+        className={`${css.indicator} ${css.success}${leavingClass}`}
+        role="status"
+        aria-label={recoveredLabel}
+      >
         <span className={css.icon} aria-hidden="true"><IconCheckOutline16 size={14} /></span>
-        <span className={css.label}>
-          {sizeLabels}
-          <span className={css.stateLabel}>{recoveredLabel}</span>
-        </span>
+        <span className={css.label}>{recoveredLabel}</span>
       </div>
     )
   }
 
-  const connecting = state === 'connecting'
+  const connecting = rendered === 'connecting'
   return (
     <button
       type="button"
-      className={`${css.indicator} ${css.warning}`}
-      data-phase={state}
+      className={`${css.indicator} ${css.warning}${leavingClass}`}
+      data-phase={rendered}
       aria-label={connecting ? restartActionLabel : reconnectActionLabel}
       onClick={onReconnect}
     >
-      <span className={css.icon} aria-hidden="true"><IconWarningOutline16 size={14} /></span>
+      <span className={css.icon} aria-hidden="true">
+        {connecting
+          ? <IconLoadingOutline16 size={14} className={css.spinner} />
+          : <IconRefreshOutline14 size={14} />}
+      </span>
       <span className={css.label}>
-        {sizeLabels}
-        <span className={css.stateLabel}>
-          {connecting
-            ? (
-              <>
-                {connectingLabel}
-                <span className={css.dots} aria-hidden="true">
-                  <span>.</span>
-                  <span className={css.secondDot}>.</span>
-                  <span className={css.thirdDot}>.</span>
-                </span>
-              </>
-            )
-            : disconnectedLabel}
-        </span>
-        <span className={css.hoverLabel}>{reconnectLabel}</span>
+        {connecting
+          ? (
+            <>
+              {connectingLabel}
+              <span className={css.dots} aria-hidden="true">
+                <span>.</span>
+                <span className={css.secondDot}>.</span>
+                <span className={css.thirdDot}>.</span>
+              </span>
+            </>
+          )
+          : disconnectedLabel}
       </span>
     </button>
   )

+ 25 - 4
packages/client/ui-primitives/tests/atoms.client.spec.tsx

@@ -462,8 +462,7 @@ describe('ConnectionIndicator', () => {
   it('renders outage, attempt progress, and recovered states without a native tooltip', () => {
     const reconnect = vi.fn()
     const labels = {
-      disconnectedLabel: 'Disconnected',
-      reconnectLabel: 'Reconnect',
+      disconnectedLabel: 'Disconnected, retry',
       connectingLabel: 'Connecting',
       recoveredLabel: 'Connected',
       reconnectActionLabel: 'Disconnected, reconnect now',
@@ -476,8 +475,7 @@ describe('ConnectionIndicator', () => {
     expect(container.firstChild).toBeNull()
     rerender(<ConnectionIndicator state="disconnected" {...labels} />)
     const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
-    expect(indicator.textContent).toContain('Disconnected')
-    expect(indicator.textContent).toContain('Reconnect')
+    expect(indicator.textContent).toContain('Disconnected, retry')
     expect(indicator.hasAttribute('title')).toBe(false)
     expect(indicator.querySelector('svg')).toBeTruthy()
     fireEvent.click(indicator)
@@ -491,4 +489,27 @@ describe('ConnectionIndicator', () => {
     expect(screen.queryByRole('button')).toBeNull()
     expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
   })
+
+  it('fades out for the exit duration before unmounting', () => {
+    vi.useFakeTimers()
+    try {
+      const labels = {
+        disconnectedLabel: 'Disconnected, retry',
+        connectingLabel: 'Connecting',
+        recoveredLabel: 'Connected',
+        reconnectActionLabel: 'Disconnected, reconnect now',
+        restartActionLabel: 'Connecting, restart now',
+        onReconnect: vi.fn(),
+      }
+      const { container, rerender } = render(
+        <ConnectionIndicator state="disconnected" {...labels} />,
+      )
+      rerender(<ConnectionIndicator state={undefined} {...labels} />)
+      expect(screen.getByRole('button', { name: 'Disconnected, reconnect now' })).toBeTruthy()
+      act(() => { vi.advanceTimersByTime(150) })
+      expect(container.firstChild).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
 })

+ 2 - 2
packages/client/ui-settings-general/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-settings-general/README.md
-README.md: 845d9c48dce2264d478f0ac854ef85a480828a14
-README.zh.md: 814082f94156e44b45e015e67acb7a281ba2e7e9
+README.md: af68c42ad111f74e68037436e18a5be57195a64b
+README.zh.md: f929df66a13cde7beb63721922f2a3f4aa90ba43

+ 2 - 2
packages/client/ui-settings-general/README.md

@@ -25,7 +25,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover
 <a id="use-this-package"></a>
 ## Use this package
 
-Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
+Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows a spinner beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
 
 ### The General section
 
@@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may
 
 ### Connection recovery
 
-The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer.
+The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the 800ms minimum-visible hold for the connecting state and the two-second recovered-state timer, which starts when the recovered pill becomes visible after the hold.
 
 ### Document availability
 

+ 2 - 2
packages/client/ui-settings-general/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
+用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示 spinner 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
 
 ### 「通用」分区
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 
 ### 连接恢复
 
-外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有
+外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;连接中状态的 800ms 最短可见驻留与 2 秒恢复确认计时器归外壳所有;恢复计时从驻留结束、恢复药丸实际可见时开始
 
 ### 文档可用性
 

+ 32 - 4
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -23,6 +23,9 @@ import css from './SettingsRoot.module.css'
 
 const RECOVERY_CONFIRMATION_MS = 2_000
 
+/** Minimum visible time for the connecting pill; shorter attempts read as flicker. */
+const CONNECTING_MIN_VISIBLE_MS = 800
+
 /** Nav glyph by section id; unknown ids fall back to the settings gear. */
 function navIcon(id: string) {
   if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
@@ -113,6 +116,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   const [activeId, setActiveId] = useState<string | undefined>(undefined)
   const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
   const [showRecovery, setShowRecovery] = useState(false)
+  const [holdConnecting, setHoldConnecting] = useState(false)
+  const connectingShownAt = useRef<number | undefined>(undefined)
   const triggerButton = useRef<HTMLButtonElement | null>(null)
   const wasOpen = useRef(open)
   const close = useCallback(() => {
@@ -157,8 +162,32 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
     }
     if (previous !== 'disconnected' && previous !== 'connecting') return
     setShowRecovery(true)
+  }, [connectionState])
+
+  // The confirmation window starts when the recovered pill becomes visible,
+  // which the connecting minimum-visible hold can delay past the transition.
+  useLayoutEffect(() => {
+    if (!showRecovery || holdConnecting) return
     const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS)
     return () => { window.clearTimeout(timeout) }
+  }, [showRecovery, holdConnecting])
+
+  useLayoutEffect(() => {
+    if (connectionState === 'connecting') {
+      connectingShownAt.current = Date.now()
+      return
+    }
+    const shownAt = connectingShownAt.current
+    if (shownAt === undefined) return
+    connectingShownAt.current = undefined
+    const remaining = CONNECTING_MIN_VISIBLE_MS - (Date.now() - shownAt)
+    if (remaining <= 0) return
+    setHoldConnecting(true)
+    const timeout = window.setTimeout(() => { setHoldConnecting(false) }, remaining)
+    return () => {
+      window.clearTimeout(timeout)
+      setHoldConnecting(false)
+    }
   }, [connectionState])
 
   const completeOnboardingStep = useCallback((id: string) => {
@@ -169,10 +198,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   }, [])
 
   let connectionIndicator: ConnectionIndicatorState | undefined
-  if (connectionState === 'disconnected') {
-    connectionIndicator = 'disconnected'
-  } else if (connectionState === 'connecting') {
+  if (connectionState === 'connecting' || holdConnecting) {
     connectionIndicator = 'connecting'
+  } else if (connectionState === 'disconnected') {
+    connectionIndicator = 'disconnected'
   } else if (showRecovery) {
     connectionIndicator = 'recovered'
   }
@@ -194,7 +223,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
         <ConnectionIndicator
           state={wide ? connectionIndicator : undefined}
           disconnectedLabel={t('connection.error')}
-          reconnectLabel={t('connection.retry')}
           connectingLabel={t('connection.connecting')}
           recoveredLabel={t('connection.connected')}
           reconnectActionLabel={t('connection.reconnect')}

+ 4 - 6
packages/client/ui-settings-general/src/client/locales.ts

@@ -8,12 +8,11 @@ export const zh = {
   'openDocument': '打开配置文件',
   'openDocument.error': '无法打开配置文件',
   'general.nav': '通用设置',
-  'connection.error': '连接异常',
-  'connection.retry': '立即重连',
-  'connection.connecting': '自动重连中',
+  'connection.error': '连接异常,刷新重试',
+  'connection.connecting': '重新连接中',
   'connection.connected': '连接成功',
   'connection.reconnect': '连接异常,点击立即重连',
-  'connection.restart': '连接中断,正在自动重试,点击立即重连',
+  'connection.restart': '连接中断,正在重试,点击立即重连',
 } satisfies Record<string, string>
 
 /** The settings namespace key union. */
@@ -28,9 +27,8 @@ export const en = {
   'openDocument.error': 'Could not open configuration file',
   'general.nav': 'General',
   'connection.error': 'Disconnected',
-  'connection.retry': 'Reconnect now',
   'connection.connecting': 'Reconnecting',
   'connection.connected': 'Connected',
   'connection.reconnect': 'Disconnected, reconnect now',
-  'connection.restart': 'Reconnecting automatically, reconnect now',
+  'connection.restart': 'Reconnecting, reconnect now',
 } satisfies Record<SettingsKey, string>

+ 2 - 2
packages/client/ui-settings-general/tests/apply.client.spec.ts

@@ -118,8 +118,8 @@ describe('ui-settings-general apply', () => {
     settings.mutate.mockResolvedValueOnce(ok(english))
     const t = c.ctx.locale.bind(NS)
     expect(t('title')).toBe('设置')
-    expect(t('connection.error')).toBe('连接异常')
-    expect(t('connection.connecting')).toBe('自动重连中')
+    expect(t('connection.error')).toBe('连接异常,刷新重试')
+    expect(t('connection.connecting')).toBe('重中')
     expect(t('connection.connected')).toBe('连接成功')
     c.ctx.locale.setLocale('en')
     expect(t('close')).toBe('Close')

+ 44 - 1
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -162,14 +162,57 @@ describe('SettingsRoot trigger', () => {
     expect(mounted.reconnect).toHaveBeenCalledOnce()
 
     mounted.setConnectionState('connecting')
-    expect(screen.getByRole('button', { name: 'Reconnecting automatically, reconnect now' }).textContent)
+    expect(screen.getByRole('button', { name: 'Reconnecting, reconnect now' }).textContent)
       .toContain('Reconnecting...')
 
+    // An attempt that resolves instantly still shows the connecting pill for
+    // its 800ms minimum before the confirmation replaces it.
     mounted.setConnectionState('connected')
+    expect(screen.queryByRole('status')).toBeNull()
+    act(() => { vi.advanceTimersByTime(800) })
     expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
+    // The confirmation window is measured from visibility, not the transition.
     act(() => { vi.advanceTimersByTime(1_999) })
     expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
+    // The confirmation window closes at 2s, then the pill fades for 150ms.
+    act(() => { vi.advanceTimersByTime(1) })
+    act(() => { vi.advanceTimersByTime(150) })
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('keeps the attempt label steady through the hold and confirms for the full window', () => {
+    vi.useFakeTimers()
+    const mounted = mount({ dictionary: zh })
+    mounted.setConnectionState('connecting')
+    const attempt = screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' })
+    expect(attempt.textContent).toContain('重新连接中')
+    fireEvent.click(attempt)
+    expect(mounted.reconnect).toHaveBeenCalledOnce()
+    expect(attempt.textContent).toContain('重新连接中')
+    // An attempt that resolves mid-hold keeps its label until the hold ends.
+    act(() => { vi.advanceTimersByTime(100) })
+    mounted.setConnectionState('connected')
+    expect(screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }).textContent)
+      .toContain('重新连接中')
+    act(() => { vi.advanceTimersByTime(700) })
+    expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
+    // The full two-second confirmation follows the delayed appearance.
+    act(() => { vi.advanceTimersByTime(1_999) })
+    expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
     act(() => { vi.advanceTimersByTime(1) })
+    act(() => { vi.advanceTimersByTime(150) })
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('skips the hold when the attempt already stayed visible long enough', () => {
+    vi.useFakeTimers()
+    const mounted = mount()
+    mounted.setConnectionState('connecting')
+    act(() => { vi.advanceTimersByTime(800) })
+    mounted.setConnectionState('connected')
+    expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
+    act(() => { vi.advanceTimersByTime(2_000) })
+    act(() => { vi.advanceTimersByTime(150) })
     expect(screen.queryByRole('status')).toBeNull()
   })
 

+ 2 - 2
packages/core/system-prompt/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/core/system-prompt/README.md
-README.md: 7e4826c4fb51873db02fd80eca632c3d1dd8d550
-README.zh.md: 30f2a43908513f5fe1e7169bff7ee95c9461ea5d
+README.md: a76ae5c66f0801cb85cd8cf22d0c3ebbc68e65b8
+README.zh.md: 98c6e733399c954c5bbc56b905aefcc6b080381d

+ 4 - 2
packages/core/system-prompt/README.md

@@ -63,6 +63,8 @@ ctx.systemPrompt.section({
 })
 ```
 
+Set `interpolate: false` on a section to preserve its text literally, including `{{…}}` groups in generated tool documentation. Other sections interpolate variables by default.
+
 ### Contribute a prompt variable
 
 Variables are referenced from section text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `model` and `cwd`; any plugin can register the facts it owns.
@@ -102,7 +104,7 @@ The package is a registry plus a cooperative assembly pipeline. One `assemble()`
 
 ### Assembly and rendering
 
-Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
+Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
 
 ### Scoping
 
@@ -170,7 +172,7 @@ Prefix-stable while the visible schema set, rendering, and order are unchanged.
 These limits define when prompt assembly needs special care. They are current package constraints, not a task backlog.
 
 - **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona prefix and suffix defaults, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API.
-- **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one.
+- **No inline escape syntax in interpolated text** — use `interpolate: false` when a whole section must preserve literal braces.
 - **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load.
 
 

+ 4 - 2
packages/core/system-prompt/README.zh.md

@@ -63,6 +63,8 @@ ctx.systemPrompt.section({
 })
 ```
 
+在段上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档中的 `{{…}}` 组。其他段默认执行变量插值。
+
 ### 贡献提示词变量
 
 变量在段文本中以 `{{name}}` 引用,并在每次组装时解析;带作用域变量会为该 agent 遮蔽同名全局变量。循环提供 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实。
@@ -102,7 +104,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
 
 ### 组装与渲染
 
-组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
+组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
 
 ### 作用域
 
@@ -170,7 +172,7 @@ schema token 在每次请求中重复。限制工具会为该 agent 移除其全
 这些限制说明提示词组装何时需要特别留意。它们是当前包约束,不是待办事项清单。
 
 - **部署方编写的提示词文本只来自配置/组合**:此插件拥有全局 persona 前缀与后缀默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。
-- **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现
+- **插值文本不支持行内转义语法**:整段需要保留字面花括号时,使用 `interpolate: false`
 - **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。
 
 

+ 9 - 3
packages/core/system-prompt/src/index.ts

@@ -61,9 +61,11 @@ export interface PromptSection {
   /**
    * Static text or a provider evaluated at each assembly with that assembly's
    * {@link AssembleContext}. The text may reference `{{variable}}`s — they are
-   * interpolated later, by {@link renderPrompt}.
+   * interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
    */
   readonly text: string | ((context: AssembleContext) => string)
+  /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
+  readonly interpolate?: boolean
   /**
    * Treat this contribution as the complete system prompt. Assembly still
    * runs the cooperative waterfall so tools, contexts, and variables can be
@@ -89,6 +91,8 @@ export interface AssembledSection {
   name: string
   /** The resolved (but not yet interpolated) section text. */
   text: string
+  /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
+  interpolate?: boolean
 }
 
 /** One resolved dynamic context contribution. */
@@ -264,7 +268,8 @@ export interface Config {
 
 /**
  * Interpolate strict `{{variable}}` references, drop empty sections, and join
- * the rest with blank lines. Malformed, unknown, or undefined references throw;
+ * the rest with blank lines. Sections with `interpolate: false` retain literal
+ * text. Malformed, unknown, or undefined references in other sections throw;
  * a lone `{{` without any later `}}` is literal prose, and substituted values
  * are not scanned again.
  * @param assembly - the assembly whose sections and variables to render.
@@ -272,7 +277,7 @@ export interface Config {
  */
 export function renderPrompt(assembly: PromptAssembly): string {
   return assembly.sections
-    .map(section => interpolate(section, assembly.variables, 'section'))
+    .map(section => section.interpolate === false ? section.text : interpolate(section, assembly.variables, 'section'))
     .filter(text => text.length > 0)
     .join('\n\n')
 }
@@ -597,6 +602,7 @@ export class SystemPrompt extends Service {
         const assembled = {
           name: section.name,
           text: typeof section.text === 'function' ? section.text(context) : section.text,
+          ...section.interpolate !== undefined ? { interpolate: section.interpolate } : {},
         }
         if (section.complete === true) completeSection = { ...assembled }
         return assembled

+ 19 - 0
packages/core/system-prompt/tests/system-prompt.spec.ts

@@ -579,6 +579,25 @@ describe('SystemPrompt', () => {
       expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`)
     })
 
+    it.each([
+      [false, false],
+      [false, true],
+      [true, false],
+      [true, true],
+    ])('preserves literal section text with complete=%s and dynamic=%s', async (complete, dynamic) => {
+      const ctx = new Context()
+      try {
+        await ctx.plugin(SystemPrompt, { includeHarnessIdentity: false, personaPrefix: '{{model}}' })
+        ctx.systemPrompt.variable('model', () => 'actual-model')
+        const text = '{{item}} {{model}} {{ model }} {{nested{{item}}}}'
+        ctx.systemPrompt.section({ name: 'literal', order: 1, text: dynamic ? () => text : text, interpolate: false, complete })
+        expect(renderPrompt(await ctx.systemPrompt.assemble()))
+          .toBe(complete ? text : `actual-model\n\n${text}`)
+      } finally {
+        await ctx.fiber.dispose()
+      }
+    })
+
     it('lets a waterfall listener add or override variables before render', async () => {
       const ctx = new Context()
       await ctx.plugin(SystemPrompt)

+ 2 - 2
packages/core/tools/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/core/tools/README.md
-README.md: a62e89c1bde0b96d5ba6b0e7c8f27d2af87f4340
-README.zh.md: d10ce3913cc9eb24e417f61c5b9f081a40b50cc3
+README.md: 17e2eb05bac152c75f7a906bf6e15f26517d38dc
+README.zh.md: cd08e9282e2ac6e75c1694d9989a4079d65985b5

+ 1 - 1
packages/core/tools/README.md

@@ -170,7 +170,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
 
 #### What the model sees
 
-PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
+PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
 
 ##### TypeScript PTC mode SDK instructions with bash
 

+ 1 - 1
packages/core/tools/README.zh.md

@@ -170,7 +170,7 @@ ctx.tools.register(defineTool({
 
 #### 模型看到什么
 
-PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
+PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
 
 ##### 带 bash 的 TypeScript PTC mode SDK 说明
 

+ 4 - 3
packages/core/tools/src/index.ts

@@ -13,7 +13,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { UserMessage } from '@deepseek-ai/dsh-session'
 import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
-import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
+import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
 import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
 // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
 // augmentation. The seam stays optional at runtime — see `serviceAsk`.
@@ -844,7 +844,7 @@ export class ToolRuntime extends Service {
    * `both` renders empty: native calls do execute there, so the rule is false.
    * @returns the section registration.
    */
-  private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
+  private collapseSection(): PromptSection {
     return {
       name: 'tools:ptc-only',
       order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'),
@@ -864,10 +864,11 @@ export class ToolRuntime extends Service {
    * dropped from the rendered prompt.
    * @returns the section registration.
    */
-  private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
+  private sdkSection(): PromptSection {
     return {
       name: 'tools:sdk',
       order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'),
+      interpolate: false,
       // Regenerate from the calling scope's visible tools in stable order.
       text: (context) => {
         const mode = this.modeFor(context.scope)

+ 20 - 0
packages/core/tools/tests/fixtures/literal-sdk.ts

@@ -0,0 +1,20 @@
+/** Tool documentation containing literal template syntax for recorded PTC replay. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+
+export const name = 'literal-sdk'
+export const inject = ['tools']
+
+export function apply(ctx: Context): void {
+  ctx.tools.register(defineTool({
+    name: 'template_echo',
+    description: 'Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution.',
+    parameters: { text: { type: 'string', required: true } },
+    output: {
+      schema: { type: 'string' },
+      render: (_args, value) => [{ type: 'text', text: value }],
+    },
+    execute: args => Promise.resolve(args.text),
+  }))
+}

+ 33 - 1
packages/core/tools/tests/ptc.spec.ts

@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { createUserMessage, ToolCallId  } from '@deepseek-ai/dsh-llm'
 import { createScope } from '@deepseek-ai/dsh-scope'
 import type { Scope } from '@deepseek-ai/dsh-scope'
-import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
 import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
 import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
@@ -117,6 +117,38 @@ async function runCode(
 }
 
 describe('mode-aware wire contribution', () => {
+  it.each([
+    { mode: 'ptc', language: 'typescript' },
+    { mode: 'both', language: 'typescript' },
+    { mode: 'ptc', language: 'python' },
+    { mode: 'both', language: 'python' },
+  ] as const)('preserves literal braces in the $language SDK under $mode', async ({ mode, language }) => {
+    const { ctx, systemPrompt } = await setup({ mode, runtime: { language } })
+    try {
+      systemPrompt.variable('model', () => 'actual-model')
+      const description = 'Expand {{item}} with {{model}} or {{ model }}.'
+      ctx.tools.register(defineTool({
+        name: 'template',
+        description,
+        parameters: { value: { type: 'string', description, enum: ['{{item}}', '{{model}}'], required: true } },
+        output: {
+          schema: { type: 'string', enum: ['{{item}}', '{{model}}'] },
+          render: (_args, value) => [{ type: 'text', text: value }],
+        },
+        execute: args => Promise.resolve(args.value),
+      }))
+      const assembly = await systemPrompt.assemble()
+      const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
+      expect(sdk).toBeDefined()
+      const prompt = renderPrompt(assembly)
+      expect(prompt).toContain(description)
+      expect(prompt).toContain(sdk!.text)
+      expect(prompt).not.toContain('actual-model')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => {
     const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false })
     registerEcho(ctx)

+ 65 - 8
packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts

@@ -2,16 +2,13 @@ import { EventEmitter } from 'node:events'
 import { existsSync } from 'node:fs'
 import { dirname } from 'node:path'
 import { PassThrough } from 'node:stream'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 
 /**
- * A synchronous `proto.write` throw on the fd-3 pipe is the one boot path a real
- * subprocess cannot be coerced into from a test: the pipe accepts queued bytes
- * until the kernel buffer fills, and a same-tick EPIPE needs fd 3 already closed
- * before the first write. `spawn` is mocked so fd 3 throws on the boot frame,
- * which is exactly the branch that regressed. The mock is confined to this file
- * so the real-subprocess suite in runtime.spec.ts is untouched.
+ * Mocked subprocess pipes control synchronous write failures and backpressure
+ * transitions independently of kernel buffering. The real-subprocess suite
+ * remains in runtime.spec.ts.
  */
 const { execFileSyncMock, spawnMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), spawnMock: vi.fn() }))
 vi.mock('node:child_process', async (importOriginal) => {
@@ -128,7 +125,67 @@ function fakeChildBackpressuredThenDestroyed(): { child: EventEmitter; proto: Pa
   return { child, proto }
 }
 
-describe('PythonCodeRuntime — boot-write failure', () => {
+describe('PythonCodeRuntime — controlled subprocess pipes', () => {
+  it('preserves pending replies across compaction while the pipe stays backpressured', async () => {
+    const spawned = Promise.withResolvers<undefined>()
+    let written = Promise.withResolvers<undefined>()
+    const replies: unknown[] = []
+    const proto = new PassThrough()
+    const stdout = new PassThrough()
+    const stderr = new PassThrough()
+    const stdin = new PassThrough()
+    const child = Object.assign(new EventEmitter(), { stdout, stderr, stdio: [stdin, stdout, stderr, proto] })
+    proto.write = (chunk: unknown) => {
+      const frame = JSON.parse(String(chunk)) as { type: string }
+      if (frame.type !== 'reply') return true
+      replies.push(frame)
+      written.resolve(undefined)
+      return false
+    }
+    spawnMock.mockImplementation(() => { spawned.resolve(undefined); return child })
+    const ctx = new Context()
+    const fiber = await ctx.plugin(PythonCodeRuntime)
+    const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
+    const run = runtime.run({
+      program: 'return 1',
+      bindings: [{ global: 'tools', functions: { echo: async (value: unknown) => value as number } }],
+    })
+    onTestFinished(async () => {
+      await fiber.dispose()
+      await run
+      for (const stream of [stdin, stdout, stderr, proto]) stream.destroy()
+    })
+    const calls = (start: number): void => {
+      proto.emit('data', Buffer.from(Array.from({ length: 512 }, (_, offset) => JSON.stringify({
+        type: 'call', id: start + offset, global: 'tools', name: 'echo', args: start + offset,
+      })).join('\n') + '\n'))
+    }
+    const drainThrough = async (count: number): Promise<void> => {
+      while (replies.length < count) {
+        written = Promise.withResolvers<undefined>()
+        expect(proto.listenerCount('drain')).toBe(1)
+        proto.emit('drain')
+        await written.promise
+      }
+    }
+    await spawned.promise
+    proto.emit('data', Buffer.from('{"type":"boot-ack"}\n'))
+    calls(0)
+    await written.promise
+    await drainThrough(256)
+    calls(512)
+    await drainThrough(768)
+    calls(1024)
+    // Each write remains blocked until this fixture emits drain, keeping
+    // pending replies behind the consumed-prefix compaction at frame 1024.
+    await drainThrough(1536)
+    expect(replies).toEqual(Array.from({ length: 1536 }, (_, id) => ({ type: 'reply', id, ok: true, value: id })))
+    proto.emit('drain')
+    proto.emit('data', Buffer.from('{"type":"done","value":"done"}\n'))
+    expect(await run).toMatchObject({ value: 'done' })
+    expect(proto.listenerCount('drain')).toBe(0)
+  })
+
   it('force-kills a version probe that exceeds its load-time deadline', async () => {
     const ctx = new Context()
     const fiber = await ctx.plugin(PythonCodeRuntime)

+ 0 - 148
packages/experimental/code-runtime-python/tests/runtime.spec.ts

@@ -1494,106 +1494,6 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     await fiber.dispose()
   })
 
-  it('bounds an illegal-UTF-8 native residual by its U+FFFD-decoded cost', async () => {
-    // Every 0xFF byte is illegal in any UTF-8 sequence, so `toString('utf8')`
-    // renders each as U+FFFD (3 serialized bytes). `accrueStrayCost` must charge
-    // that 3, not the raw 1: otherwise the newline-free residual grows to a full
-    // budget's worth of RAW bytes before flushing — a ~3x undercount that near a
-    // large maxLogBytes retains hundreds of MiB then expands toward a ~1 GiB peak
-    // in flushStray's concat + toString. Paced single-byte writes (each its own
-    // `data` chunk, like the sealing case) expose the sub-chunk accrual: charged
-    // at 3 the residual crosses a 3072-byte budget after ~1024 bytes and flushes;
-    // charged at 1 it would need ~3072 bytes, so the peak residual triples. The
-    // largest merged buffer is the discriminator.
-    const realConcat = Buffer.concat.bind(Buffer)
-    let maxConcat = 0
-    Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
-      const merged = realConcat(list, total)
-      if (merged.length > maxConcat) maxConcat = merged.length
-      return merged
-    }
-    let result: CodeRunResult
-    try {
-      const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
-      result = await runtime.run({
-        program: [
-          'import os, time',
-          // One byte per chunk on every host: a plain yield lets a loaded
-          // reader coalesce, and the coalesced chunk is what the bound below
-          // measures. The payload stays above the 2048 discriminator, so a
-          // raw-byte undercount still flushes the whole residual at EOF.
-          'for _ in range(3200):',
-          '    os.write(1, b"\\xff")',
-          '    time.sleep(0.001)',
-          'return None',
-        ].join('\n'),
-        bindings: [],
-      })
-    } finally {
-      Buffer.concat = realConcat
-    }
-    expect(result.error).toBeUndefined()
-    expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
-    // Charged at 3, the residual flushes around 1024 raw bytes; the largest
-    // merged buffer stays well under 2048. A raw-byte undercount would let it
-    // reach ~3072 before flushing, so 2048 discriminates.
-    expect(maxConcat).toBeLessThan(2048)
-    // The paced payload costs ~3.2s deterministically, which is above the
-    // 5000ms default the local unit entry grants, so the case carries its own
-    // bound instead of relying on the lane to widen it.
-  }, 20_000)
-
-  it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => {
-    // A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte
-    // lead plus two 0x80–0xBF continuations) but ILLEGAL: `toString('utf8')`
-    // renders each of the three bytes as its own U+FFFD (serialized cost 9), not
-    // one width-3 character. The newline-free flush trigger weighs the residual
-    // through `accrueStrayCost`, which must validate each lead's
-    // first-continuation range (ED excludes A0–BF) and charge the true 9 — else a
-    // CESU flood undercounts 3x and the residual grows toward a full budget's raw
-    // bytes before flushing, the same peak-memory vector as the 0xFF case. The
-    // bytes are written one at a time (each its own `data` chunk, no pipe
-    // coalescing) and `Buffer.concat` is wrapped to measure the peak residual.
-    const realConcat = Buffer.concat.bind(Buffer)
-    let maxConcat = 0
-    Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
-      const merged = realConcat(list, total)
-      if (merged.length > maxConcat) maxConcat = merged.length
-      return merged
-    }
-    let result: CodeRunResult
-    try {
-      const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
-      result = await runtime.run({
-        program: [
-          'import os, time',
-          'seq = (0xed, 0xa0, 0x80)',
-          // 1100 sequences are 3300 raw bytes, past the 3072-byte budget a
-          // raw-byte undercount reaches, so the undercount flushes above the
-          // 2048 discriminator instead of only at EOF.
-          'for _ in range(1100):',
-          '    for b in seq:',
-          '        os.write(1, bytes((b,)))',
-          '        time.sleep(0.001)',
-          'return None',
-        ].join('\n'),
-        bindings: [],
-      })
-    } finally {
-      Buffer.concat = realConcat
-    }
-    expect(result.error).toBeUndefined()
-    expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
-    // Each 3-byte sequence costs 9 (three U+FFFD), so single-byte-paced the
-    // residual crosses the 3072 budget after ~342 raw bytes and flushes; the
-    // largest merged buffer stays well under 2048. Charging the structural width
-    // 3 would need ~1024 raw bytes, tripling the peak past 2048.
-    expect(maxConcat).toBeLessThan(2048)
-    // The paced payload costs ~3.3s deterministically, which is above the
-    // 5000ms default the local unit entry grants, so the case carries its own
-    // bound instead of relying on the lane to widen it.
-  }, 20_000)
-
   it('charges a lone surrogate its full six escaped bytes, not three', async () => {
     // A forged `log` frame carrying `\ud800` escapes materializes lone
     // surrogates after JSON.parse. `Buffer.byteLength` of U+FFFD is 3, but
@@ -5511,54 +5411,6 @@ describe('PythonCodeRuntime — hostile peer', () => {
     expect(result.value).toContain('duplicate dict key')
   }, 30_000)
 
-  it('compacts the reply queue mid-drain without dropping pending frames', async () => {
-    // A reply larger than the writable high-water mark makes the FIRST write
-    // return false, suspending the drain loop; the frames queued behind it
-    // push the drain's consumed head past MAX_PENDING_REPLIES, so the resumed
-    // drain compacts the queue mid-run. The child reads fd 3 itself (blocking
-    // the asyncio pump, so its reads cannot race the host's pushes) and sends
-    // a second wave of calls AFTER reading part of the first wave's replies —
-    // those replies are still pending when the drain's head crosses the
-    // compaction bound, so a compaction that dropped pending frames would
-    // leave the child's reply count short and the read loop spinning to the
-    // wall clock. No fixed sleep: the child's reads pace at the drain's
-    // delivery rate (each write blocks until the child reads), and the host
-    // finishes pushing all of a wave within milliseconds — orders of magnitude
-    // before the head crosses the bound — so the queue is always full at the
-    // splice. Newlines are counted per chunk (each reply carries exactly one),
-    // never by re-scanning the accumulated total, which would be O(n²).
-    const { runtime } = await setup({ maxWallMs: 60_000 })
-    const result = await runtime.run({
-      program: [
-        'import os',
-        'frame = b\'{"type":"call","id":%d,"global":"tools","name":"big","args":{}}\\n\'',
-        'for i in range(1024):',
-        '    view = memoryview(frame % i)',
-        '    while view:',
-        '        view = view[os.write(3, view):]',
-        'seen = 0',
-        'while seen < 500:',
-        '    chunk = os.read(3, 65536)',
-        '    if not chunk:',
-        '        break',
-        '    seen += chunk.count(b"\\n")',
-        'for i in range(500):',
-        '    view = memoryview(frame % (1024 + i))',
-        '    while view:',
-        '        view = view[os.write(3, view):]',
-        'while seen < 1524:',
-        '    chunk = os.read(3, 65536)',
-        '    if not chunk:',
-        '        break',
-        '    seen += chunk.count(b"\\n")',
-        'return "done"',
-      ].join('\n'),
-      bindings: [{ global: 'tools', functions: { big: async () => 'x'.repeat(65 * 1024) } }],
-    })
-    expect(result.error).toBeUndefined()
-    expect(result.value).toBe('done')
-  }, 60_000)
-
   it('bounds a flood of zero-byte log lines through the per-entry separator charge', async () => {
     // Blank print() lines carry zero content bytes; without the +1 separator
     // charge they would bypass maxLogBytes entirely and grow the retained

+ 31 - 0
packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts

@@ -1,5 +1,6 @@
 import { Context } from '@deepseek-ai/cordis'
 import { expect, it, vi } from 'vitest'
+import { logTruncationMarker } from '../src/protocol.ts'
 
 // Keep the interpreter and pipe lifecycle real; only OS-dependent read sizes
 // change. Each byte reaches the runtime as its own data event.
@@ -54,3 +55,33 @@ it('seals stray fragments without recopying the sealed prefix', async () => {
     await fiber.dispose()
   }
 }, 40_000)
+
+it.each([
+  { name: 'illegal UTF-8 bytes', payload: 'b"\\xff" * 3200' },
+  { name: 'CESU-8 lone surrogates', payload: 'b"\\xed\\xa0\\x80" * 1100' },
+])('bounds $name by their U+FFFD-decoded cost', async ({ payload }) => {
+  const ctx = new Context()
+  const fiber = await ctx.plugin(PythonCodeRuntime, { maxLogBytes: 3072, maxWallMs: 30_000 })
+  const realConcat = Buffer.concat.bind(Buffer)
+  let maxConcat = 0
+  const concat = vi.spyOn(Buffer, 'concat').mockImplementation((list, total) => {
+    const merged = realConcat(list, total)
+    maxConcat = Math.max(maxConcat, merged.length)
+    return merged
+  })
+  try {
+    const result = await ctx.codeRuntime.run({
+      program: `import os\nos.write(1, ${payload})\nreturn None`,
+      bindings: [],
+    })
+    expect(result.error).toBeUndefined()
+    expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
+    // Each raw byte decodes to U+FFFD (three UTF-8 bytes), so a 3072-byte
+    // budget flushes near 1024 raw bytes. Charging raw or structural widths
+    // instead retains over 2048 bytes before flushing these payloads.
+    expect(maxConcat).toBeLessThan(2048)
+  } finally {
+    concat.mockRestore()
+    await fiber.dispose()
+  }
+}, 20_000)

+ 2 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3704,7 +3704,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'AssembledSection',
-    declaration: 'export interface AssembledSection {\n    name: string;\n    text: string;\n}',
+    declaration: 'export interface AssembledSection {\n    name: string;\n    text: string;\n    interpolate?: boolean;\n}',
   },
   {
     name: 'AssistantMessage',
@@ -4840,7 +4840,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PromptSection',
-    declaration: 'export interface PromptSection {\n    readonly name: string;\n    readonly order: number;\n    readonly text: string | ((context: AssembleContext) => string);\n    readonly complete?: boolean;\n}',
+    declaration: 'export interface PromptSection {\n    readonly name: string;\n    readonly order: number;\n    readonly text: string | ((context: AssembleContext) => string);\n    readonly interpolate?: boolean;\n    readonly complete?: boolean;\n}',
   },
   {
     name: 'PromptSectionOrderName',

+ 2 - 2
packages/subprocess/subprocess-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: 3222d7bf9b9b498baf939212ebbf7da9bf599d76
-README.zh.md: e8d2ddc9398ffe186260a1b4ed3a0cd116ff8afb
+README.md: 10f0b01d147dfd0afefd2214264084e55d2b213d
+README.zh.md: 14479e688f122712041741270b995ca47a2e1166

+ 1 - 1
packages/subprocess/subprocess-local/README.md

@@ -52,7 +52,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina
 
 Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
 
-Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup.
+Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. A failed final signal does not reject a range subsequently proven empty; active ranges without that proof retain the signal failure.
 
 ### What can go wrong
 

+ 1 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-reference"
 
 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。
 
-Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。
+Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。即使最终信号发送失败,之后证明范围已为空仍可成功结束;未获得这一证明的 active 范围仍报告信号失败。
 
 ### 可能出错的地方
 

+ 4 - 1
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -318,6 +318,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
 
   private async rangeActive(): Promise<boolean> {
     this.observeRequestConsumption()
+    const generation = this.wakeGeneration
     const result = await this.query(this.systemctl, [
       '--user',
       'show',
@@ -326,6 +327,8 @@ class SystemdScopeOwner implements BoundProcessOwner {
       '--property=ActiveState',
       '--property=TasksCurrent',
     ])
+    // A signal invalidates state queried before its delivery and direct fallback.
+    if (generation !== this.wakeGeneration) return true
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status === 0) {
       const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout)
@@ -340,11 +343,11 @@ class SystemdScopeOwner implements BoundProcessOwner {
       if (!['active', 'activating', 'reloading', 'deactivating'].includes(activeState)) {
         throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`)
       }
-      if (this.killFailure !== undefined) throw this.killFailure
       if (this.emptyRange(tasksCurrent)) {
         this.releaseEmptyRange()
         return false
       }
+      if (this.killFailure !== undefined) throw this.killFailure
       return true
     }
     if (!MISSING_UNIT.test(output)) {

+ 76 - 0
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -430,6 +430,82 @@ describe('Linux scope establishment and quiescence', () => {
     killFailed.result.owner.cleanup?.()
   })
 
+  it('rechecks a pre-signal observation before reporting a failed final kill', async () => {
+    denyProcessGroups()
+    const beforeKill = Promise.withResolvers<ReturnType<typeof activeUnit>>()
+    const query = vi.fn()
+      .mockImplementationOnce(() => beforeKill.promise)
+      .mockResolvedValueOnce(activeUnit('inactive'))
+    const sleep = vi.fn(async () => {})
+    const launched = launch(query, {
+      sleep,
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
+    })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    const waiting = launched.result.owner.waitForExit()
+    launched.result.owner.signal('SIGKILL')
+    launched.child.exit(null, 'SIGKILL')
+    beforeKill.resolve(activeUnit())
+    await expect(waiting).resolves.toBeUndefined()
+    await expect(launched.result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
+    expect(query).toHaveBeenCalledTimes(2)
+    expect(sleep).not.toHaveBeenCalled()
+    launched.result.owner.cleanup?.()
+  })
+
+  it('does not accept a pre-signal empty observation when the fresh range remains populated', async () => {
+    denyProcessGroups()
+    const beforeKill = Promise.withResolvers<ReturnType<typeof activeUnit>>()
+    const query = vi.fn()
+      .mockImplementationOnce(() => beforeKill.promise)
+      .mockResolvedValueOnce(activeUnitWithTasks('1'))
+    const launched = launch(query, {
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
+    })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    const waiting = launched.result.owner.waitForExit()
+    launched.result.owner.signal('SIGKILL')
+    launched.child.exit(null, 'SIGKILL')
+    beforeKill.resolve(activeUnit('inactive'))
+    await expect(waiting).rejects.toThrow('Invalid argument')
+    await launched.result.direct
+    expect(query).toHaveBeenCalledTimes(2)
+    launched.result.owner.cleanup?.()
+  })
+
+  it('accepts a confirmed empty range after a failed final kill', async () => {
+    denyProcessGroups()
+    const spawnSync = recordingSystemctl()
+      .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
+    const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    launched.child.exit(null, 'SIGKILL')
+    await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined()
+    await expect(launched.result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
+    expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill', 'stop'])
+    launched.result.owner.cleanup?.()
+  })
+
+  it.each([
+    { tasks: '1', clientRunning: false },
+    { tasks: '[not set]', clientRunning: false },
+    { tasks: '0', clientRunning: true },
+  ])('retains a failed kill with tasks=$tasks and clientRunning=$clientRunning', async ({ tasks, clientRunning }) => {
+    denyProcessGroups()
+    const spawnSync = recordingSystemctl()
+      .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
+    const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    if (!clientRunning) launched.child.exit(null, 'SIGKILL')
+    await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')
+    expect(spawnSync).toHaveBeenCalledOnce()
+    if (clientRunning) launched.child.exit(null, 'SIGKILL')
+    await launched.result.direct
+    launched.result.owner.cleanup?.()
+  })
+
   it('reports command-query failures from the default systemctl adapter', async () => {
     childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => {
       const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void

+ 3 - 3
packages/terminal/terminal-bash/tests/local.spec.ts

@@ -330,9 +330,9 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => {
         timeoutMs: 8_000,
       }, 'pwsh')
       const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
-      // Linux stdin-wait evidence can arrive before the PTY delivers the rendered prompt.
-      await expect.poll(() => ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text, { timeout: 8_000 })
-        .toContain('dsh> ')
+      // stdin_read can precede delivery of the printable prompt to the PTY reader.
+      await expect.poll(() => ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text,
+        { timeout: 8_000 }).toContain('dsh> ')
 
       const releaseFile = join(root, 'release-command')
       // Hold the command across the silence settlement without relying on host load.

+ 9 - 3
scripts/browser-bundled-externals.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
@@ -78,7 +78,7 @@ describe('browser dependency discovery', () => {
     await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config')
   })
 
-  it('follows shell workspace aliases, CSS assets and lazy imports without writing output', async () => {
+  it.each([false, true])('follows shell aliases, CSS and lazy imports without writing output (symlinked root: %s)', async (linked) => {
     const root = fixture()
     library(root, 'shell-lib')
     library(root, 'lazy-lib')
@@ -104,7 +104,13 @@ describe('browser dependency discovery', () => {
     }`)
     write(root, 'apps/web/dist/sentinel.txt', 'untouched')
 
-    expect(await browserBundledExternals(root)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
+    const scanRoot = linked ? join(fixture(), 'linked') : root
+    if (linked) symlinkSync(root, scanRoot, 'junction')
+    try {
+      expect(await browserBundledExternals(scanRoot)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
+    } finally {
+      if (linked) unlinkSync(scanRoot)
+    }
     expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched')
     expect(existsSync(join(app, 'dist/index.html'))).toBe(false)
     expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false)

+ 4 - 2
scripts/browser-bundled-externals.ts

@@ -1,6 +1,6 @@
 /** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */
 
-import { globSync, readFileSync } from 'node:fs'
+import { globSync, readFileSync, realpathSync } from 'node:fs'
 import { createRequire } from 'node:module'
 import { dirname, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -159,10 +159,12 @@ async function collectShell(
 
 /**
  * Direct third-party packages resolved by published browser builds.
- * @param root - Repository root with installed build dependencies; lib/ is not required.
+ * @param root - Repository root, possibly symlinked, with installed build dependencies; lib/ is not required.
  * @returns Names of distributed browser inputs, excluding workspace packages and erased types.
  */
 export async function browserBundledExternals(root: string): Promise<Set<string>> {
+  // Vite resolves HTML inputs to real paths, so its root must use the same spelling.
+  root = realpathSync(root)
   const manifests = new Map<string, Manifest>()
   for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) {
     for (const path of globSync(glob, { cwd: root }).sort()) {

+ 25 - 0
scripts/run-gates.spec.ts

@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
 import { describe, expect, it, vi, type MockInstance } from 'vitest'
 import {
   cliGateOptions,
+  collectDescendants,
   defaultConcurrency,
   formatGateResultReason,
   gatesForMode,
@@ -917,6 +918,30 @@ describe('fail-fast scheduling', () => {
 })
 
 describe('process-table parsing', () => {
+  it('excludes the root when a parent link returns to it', () => {
+    expect(collectDescendants(100, [[200, 100], [100, 200], [300, 200]]))
+      .toEqual([200, 300])
+  })
+
+  it('visits duplicate and cyclic descendant links only once', () => {
+    expect(collectDescendants(100, [
+      [200, 100], [200, 100], [300, 100], [200, 200], [400, 200], [200, 400], [500, 300], [900, 800],
+    ])).toEqual([200, 300, 400, 500])
+  })
+
+  it('returns no descendants for an isolated or self-parented root', () => {
+    expect(collectDescendants(100, [])).toEqual([])
+    expect(collectDescendants(100, [[100, 100]])).toEqual([])
+  })
+
+  it('walks a wide child set without spreading it into call arguments', () => {
+    const children = Array.from({ length: 150_000 }, (_, i): [number, number] => [i + 3, 2])
+    const descendants = collectDescendants(1, [[2, 1], ...children])
+    expect(descendants).toHaveLength(children.length + 1)
+    expect(descendants[0]).toBe(2)
+    expect(descendants.at(-1)).toBe(150_002)
+  })
+
   it('parses `pid ppid` rows from a POSIX ps dump', () => {
     expect(parsePidPpidLines('  123   1\n456 123\n  789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
   })

+ 16 - 10
scripts/run-gates.ts

@@ -1505,23 +1505,29 @@ export function taskkillArgs(rootPid: number, descendants: number[]): string[][]
   return [rootPid, ...descendants].map(pid => ['/PID', String(pid), '/T', '/F'])
 }
 
-/** Breadth-first walk of the pid/ppid rows starting at `root`. */
-function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
+/**
+ * Walk a process-table snapshot without revisiting duplicate or cyclic PID links.
+ * @param root - process whose descendants are collected; excluded from the result.
+ * @param rows - observed PID and parent PID pairs.
+ * @returns distinct reachable descendants in breadth-first order.
+ */
+export function collectDescendants(root: number, rows: Array<[number, number]>): number[] {
   const byParent = new Map<number, number[]>()
   for (const [pid, ppid] of rows) {
     const children = byParent.get(ppid) ?? []
     children.push(pid)
     byParent.set(ppid, children)
   }
-  const result: number[] = []
-  const queue = byParent.get(root) ?? []
-  for (let index = 0; index < queue.length; index += 1) {
-    const pid = queue[index]
-    if (pid === undefined) continue
-    result.push(pid)
-    queue.push(...(byParent.get(pid) ?? []))
+  const seen = new Set([root])
+  const queue = [root]
+  for (const parent of queue) {
+    for (const pid of byParent.get(parent) ?? []) {
+      if (seen.has(pid)) continue
+      seen.add(pid)
+      queue.push(pid)
+    }
   }
-  return result
+  return queue.slice(1)
 }
 
 /**

+ 4 - 0
snapshots/session/ptc-turn/cordis.snapshot.yml

@@ -1,5 +1,9 @@
 # Keyless PTC mode combines the runtime/registry changes with the
 # DeepSeek-to-replay swap in one profile patch.
+- insert:
+    - id: literal-sdk
+      name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
+
 - id: llm-deepseek
   name: '@deepseek-ai/dsh-llm-deepseek'
   disabled: true

+ 4 - 0
snapshots/session/ptc-turn/cordis.yml

@@ -1,6 +1,10 @@
 # PTC mode adds `ctx.codeRuntime` and changes the registry to one wire tool,
 # `run_code`, plus its generated TypeScript SDK prompt. The demo and snapshot
 # recorder apply this profile patch; replay applies its sibling patch.
+- insert:
+    - id: literal-sdk
+      name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
+
 - id: agent-default-model
   name: '@deepseek-ai/dsh-agent-default-model'
   config:

+ 5 - 0
snapshots/session/ptc-turn/system-prompt.expected.md

@@ -191,6 +191,10 @@ interface ToolArgsMap {
     /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */
     prompt: string;
   } & Record<string, JsonValue>;
+  /** Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution. */
+  template_echo: {
+    text: string;
+  } & Record<string, JsonValue>;
   /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
   todo_write: {
     /** The COMPLETE task list, replacing any previous list. */
@@ -466,6 +470,7 @@ interface ToolOutputMap {
     runId: string;
     output: JsonValue[];
   };
+  template_echo: string;
   todo_write: {
     todos: ({
       content: string;

+ 4 - 0
snapshots/session/ptc-workspace-context/cordis.snapshot.yml

@@ -1,5 +1,9 @@
 # Keyless replay counterpart of ptc-workspace-context.cordis.yml. It adds
 # PTC mode to the default filesystem suite and swaps in replay.
+- insert:
+    - id: literal-sdk
+      name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
+
 - id: llm-deepseek
   name: '@deepseek-ai/dsh-llm-deepseek'
   disabled: true

+ 4 - 0
snapshots/session/ptc-workspace-context/cordis.yml

@@ -1,5 +1,9 @@
 # PTC mode agent-instructions snapshot recording overlay. The default filesystem
 # tools trigger nested instruction discovery after a read.
+- insert:
+    - id: literal-sdk
+      name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
+
 - id: agent-default-model
   name: '@deepseek-ai/dsh-agent-default-model'
   config:

+ 1 - 1
snapshots/web/lifecycle-chrome/connection-error.expected.md

@@ -1,4 +1,4 @@
 - button "Settings":
   - img
   - text: Settings
-- button "Reconnecting automatically, reconnect now": Reconnect now
+- button "Reconnecting, reconnect now": Reconnecting

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно