Selaa lähdekoodia

Merge remote-tracking branch 'origin/master' into worktree/plan-sidebar-cards

# Conflicts:
#	packages/client/ui-deliverables/README.i18n.yaml
#	packages/client/ui-deliverables/README.md
#	packages/client/ui-deliverables/README.zh.md
Yichen Jiang 3 päivää sitten
vanhempi
sitoutus
9d93fbd7ad
60 muutettua tiedostoa jossa 698 lisäystä ja 93 poistoa
  1. 2 2
      .agents/notes/README.i18n.yaml
  2. 2 2
      .agents/notes/README.md
  3. 2 2
      .agents/notes/README.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  5. 2 0
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  6. 2 0
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml
  8. 33 0
      .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
  9. 33 0
      .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md
  10. 6 0
      .agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml
  11. 35 0
      .agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md
  12. 35 0
      .agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md
  13. 1 1
      .agents/skills/dsh-archive-agent-notes/SKILL.md
  14. 1 1
      AGENTS.md
  15. 1 1
      apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md
  16. 81 0
      apps/web/tests/markdown-file-links.e2e.ts
  17. 6 0
      apps/web/tests/settings-chrome.e2e.ts
  18. 1 0
      apps/web/tsconfig.json
  19. 1 1
      docs/AGENTS.md
  20. 2 2
      packages/client/ui-chat/README.i18n.yaml
  21. 2 0
      packages/client/ui-chat/README.md
  22. 2 0
      packages/client/ui-chat/README.zh.md
  23. 1 1
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  24. 10 0
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  25. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  26. 4 4
      packages/client/ui-deliverables/README.md
  27. 4 4
      packages/client/ui-deliverables/README.zh.md
  28. 6 6
      packages/client/ui-deliverables/src/index.ts
  29. 7 1
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  30. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  31. 2 0
      packages/client/ui-primitives/README.md
  32. 2 0
      packages/client/ui-primitives/README.zh.md
  33. 1 1
      packages/client/ui-primitives/src/index.ts
  34. 27 10
      packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx
  35. 9 2
      packages/client/ui-primitives/src/markdown/MarkdownText.module.css
  36. 5 3
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  37. 32 0
      packages/client/ui-primitives/src/markdown/file-link.ts
  38. 36 8
      packages/client/ui-primitives/src/markdown/render.tsx
  39. 159 0
      packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx
  40. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  41. 1 1
      packages/client/ui-settings-plugin-inventory/README.md
  42. 1 1
      packages/client/ui-settings-plugin-inventory/README.zh.md
  43. 17 9
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  44. 33 3
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  45. 2 2
      packages/deliverables/workspace-changes/README.i18n.yaml
  46. 0 0
      packages/deliverables/workspace-changes/README.md
  47. 1 1
      packages/deliverables/workspace-changes/README.zh.md
  48. 2 1
      packages/deliverables/workspace-changes/src/git.ts
  49. 17 1
      packages/deliverables/workspace-changes/tests/git.spec.ts
  50. 8 5
      packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts
  51. 8 5
      packages/subprocess/subprocess-local/tests/process-exit.spec.ts
  52. 1 1
      snapshots/web/cordis-tool-round/system-prompt.expected.md
  53. 1 1
      snapshots/web/fresh-round-trip/system-prompt.expected.md
  54. 11 0
      snapshots/web/markdown-file-links/links.expected.md
  55. 6 0
      snapshots/web/markdown-file-links/preview.expected.md
  56. 10 0
      snapshots/web/markdown-file-links/session.v3.jsonl
  57. 7 0
      snapshots/web/markdown-file-links/snapshot.yml
  58. 1 1
      snapshots/web/ptc-round/system-prompt.expected.md
  59. 1 1
      snapshots/web/schedule-catalog/system-prompt.expected.md
  60. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/README.md
-README.md: 8bf1adf875b54eb4580b4d189dfeed07c9f671c0
-README.zh.md: ff9bdb358d584d4f4ad0aea295c31d8d8fff9376
+README.md: 9fdb4039b525bbc4051ab3c8058b117e4de5f2f4
+README.zh.md: 2d7e07b963d3c4d77da9148b011e1478fa2ae5da

+ 2 - 2
.agents/notes/README.md

@@ -43,9 +43,9 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat
 
 ## When to write one
 
-Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
+Add or update an Agent Note in the same PR only for lasting decision rationale that code, tests, and existing documentation do not explain. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
 
-Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
+Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical or local edits, including local UI presentation and interaction changes, are exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
 
 An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale.
 

+ 2 - 2
.agents/notes/README.zh.md

@@ -47,9 +47,9 @@
 
 ## 何时需要写一份
 
-每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包约定、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者维护者可能合理重新审视的其他决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
+只有代码、测试和现有文档未能解释的决策理由具有长期价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
 
-更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
+更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性或局部编辑均可豁免,包括局部 UI 展示和交互调整。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
 
 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 1b0fd162be77356001bcd9224bb2ee2889e6d6c0
-2026-08-28-subprocess-native-containment.zh.md: f33643f98a6ea6a90c0780432cbe11a35a868562
+2026-08-28-subprocess-native-containment.md: 64fd59e13ea38d7f6e066d5dadec4be830c9c0af
+2026-08-28-subprocess-native-containment.zh.md: 59e51c5a5c19f55058eb3881928d4f218a22a59c

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -54,6 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
+The host-exit disposal fixture compares the provider's listener identities before and after disposal and verifies that an independently registered exit listener remains installed. Total process listener counts cannot establish provider cleanup because other process-lifetime handlers may register after provider construction.
+
 - Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
 - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
 - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -54,6 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
+宿主退出清理 fixture 对比 provider 在 disposal 前后的监听器身份,并验证独立注册的退出监听器仍然存在。进程监听器总数无法证明 provider 清理完成,因为其他进程生命周期处理器可能在 provider 构造之后注册。
+
 - provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml

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

+ 33 - 0
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md

@@ -0,0 +1,33 @@
+# Agent Note: Markdown file preview links
+
+Status: implemented
+
+English | [中文](2026-09-15-markdown-file-preview-links.zh.md)
+
+## Problem
+
+Assistant explanations link to existing source files that the turn does not modify or deliver. Restricting clickable references to produced-file mentions prevents readers from opening those sources beside the answer.
+
+## Decision
+
+Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path.
+
+The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert.
+
+The Web file-reference prompt asks for a link on every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Names default to a basename or clear alias with minimal disambiguating parents. Precise labels use `filename:24` or `filename:24–30`, while destinations keep the parser's `#L24` or `#L24-L30` syntax. The renderer preserves model-authored text; it does not rewrite labels to enforce the prompt.
+
+## Alternatives considered
+
+**Relative browser anchors.** They navigate the application URL instead of requesting Session file content.
+
+**Require a produced-file entry.** This excludes ordinary read-only explanations.
+
+**Add a preview service.** Chat already supplies the required opener and line parameter.
+
+**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes.
+
+**Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation.
+
+## Consequences
+
+Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar.

+ 33 - 0
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Markdown 文件预览链接
+
+Status: implemented
+
+[English](2026-09-15-markdown-file-preview-links.md) | 中文
+
+## Problem
+
+Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若仅允许产出文件提及成为可点击引用,读者就无法在回答旁打开这些源码。
+
+## Decision
+
+落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。
+
+现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。
+
+Web 文件引用提示词要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。名称默认使用 basename 或清楚的别名,仅添加消除歧义所需的父目录。精确标签使用 `filename:24` 或 `filename:24–30`,目标仍使用解析器支持的 `#L24` 或 `#L24-L30` 语法。渲染器保留模型原文,不通过重写标签强制满足提示词。
+
+## Alternatives considered
+
+**相对浏览器链接。** 这会跳转应用 URL,而非请求 Session 文件内容。
+
+**要求产出文件条目。** 这会排除普通只读讲解。
+
+**增加预览服务。** Chat 已提供所需打开器和行号参数。
+
+**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。
+
+**文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。
+
+## Consequences
+
+源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md
+2026-09-17-plugin-inventory-phase-dot.md: e90d49b6a3d2d8e03f662b3de531ef180d350cad
+2026-09-17-plugin-inventory-phase-dot.zh.md: d344ede75c7e0dd77a0d57720e0c1366f22e75b8

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md

@@ -0,0 +1,35 @@
+# Agent Note: The plugin inventory reserves its status dot for phases the tag cannot state
+
+Status: implemented
+
+English | [中文](2026-09-17-plugin-inventory-phase-dot.zh.md)
+
+## Problem
+
+The Settings plugin list marks every Loader entry with an enablement tag (`已启用`, `已停用`, `条件启用`, `预设中启用`, `启动失败`). It also drew a colored root-fiber status dot on enabled rows whose fiber was live. For the common `active` phase the dot and the tag stated the same thing twice on one collapsed card, while `pending`, `loading` and `unloading` — the phases the tag has no words for — were where the dot carried its own information.
+
+## Decision
+
+`PluginInventorySettingsTab` renders the status dot only for `pending`, `loading` and `unloading`. `active` and `failed` render no dot: a settled enabled row shows its enablement tag alone, and a failed row has the failure tag and card treatment. `PHASE_DOT_STATES` lists exactly the dotted phases, and `showsPhaseDot` narrows a phase to that union.
+
+Row enablement, tag tones, grouping, search and list behavior are unchanged, as are the Loader lifecycle and persistence. The expanded card still lists the phase under 运行状态 / Status, so the runtime phase stays readable without the collapsed dot.
+
+## Alternatives considered
+
+**Remove the dot for every phase.** Simpler to state, but it drops the only collapsed-state signal that a plugin is still loading or unloading — the one state the tag cannot express.
+
+**Keep the `active` dot on preset rows only.** The preset group is where the duplication was first noticed, but the global group renders the same tag-and-dot pair. A per-group exception would make one rendering rule depend on which group a row sits in, and would leave the duplicate in the global group.
+
+**Fold the phase into the enablement tag.** The tag reports the config fact the Host publishes as `enabled`; adding a runtime phase would make one control carry two lifecycles with different owners.
+
+## Consequences
+
+- A collapsed card no longer shows that an enabled entry's fiber is currently `active`. A reader who needs the phase expands the row, whose Status fact keeps it. That is the information the change deliberately gives up.
+- Rows mid-transition keep their dot, its animation and its `role="img"` name, so the accessible name still says `等待依赖`, `加载中` or `卸载中`.
+- Known gap, pre-existing and unchanged: the dot is gated on `row.enabled`, so a row a user just disabled while its fiber is still `unloading` shows `已停用` without a dot.
+
+## Testing
+
+The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`.
+
+The remaining dots' palette, halo and animation, and the tag capsule itself, stay owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md).

+ 35 - 0
.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 插件列表只为主题标签无法表达的阶段保留状态圆点
+
+Status: implemented
+
+[English](2026-09-17-plugin-inventory-phase-dot.md) | 中文
+
+## 问题
+
+设置里的插件列表给每个 Loader 条目配一个启停标签(`已启用`、`已停用`、`条件启用`、`预设中启用`、`启动失败`),同时给 fiber 存活的已启用条目画一个彩色根 fiber 状态圆点。对最常见的 `active` 阶段,圆点与标签在同一张收起卡片上把同一件事说了两遍;而标签没有词可用的 `pending`、`loading`、`unloading`,才是圆点真正携带独立信息的阶段。
+
+## 决策
+
+`PluginInventorySettingsTab` 只为 `pending`、`loading`、`unloading` 渲染状态圆点。`active` 与 `failed` 不渲染:已稳定启用的条目只显示启停标签,失败条目由失败标签和卡片样式说明。`PHASE_DOT_STATES` 只列出有圆点的阶段,`showsPhaseDot` 把阶段收窄到该联合。
+
+条目启停、标签色调、分组、搜索与列表行为不变,Loader 生命周期与持久化也不变。展开卡片仍在「运行状态」下给出阶段名,因此不画收起态圆点也能读到运行时阶段。
+
+## 否决的备选方案
+
+**所有阶段都不画圆点。** 规则更简单,但会丢掉收起态下唯一能说明插件正在加载或卸载的信号——恰恰是标签无法表达的那个状态。
+
+**只在预设分组保留 `active` 圆点。** 重复最先是在预设分组被注意到的,但全局分组渲染的是同一套标签加圆点。按分组开例外会让同一条渲染规则取决于条目落在哪个分组,而且全局分组的重复仍然存在。
+
+**把阶段并进启停标签。** 标签报告的是 Host 以 `enabled` 发布的配置事实;再塞一个运行时阶段会让一个控件承载两条归属不同的生命周期。
+
+## 后果
+
+- 收起卡片不再显示已启用条目的 fiber 当前处于 `active`。需要该阶段的读者展开条目,其「运行状态」事实仍然给出。这是本次变更刻意放弃的信息。
+- 过渡中的条目保留圆点、动画与 `role="img"` 名称,无障碍名称仍会读出 `等待依赖`、`加载中` 或 `卸载中`。
+- 既有且未改变的缺口:圆点以 `row.enabled` 为前提,用户刚停用而 fiber 仍在 `unloading` 的条目只显示 `已停用`,没有圆点。
+
+## 测试
+
+包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。
+
+其余圆点的配色、光晕与动画,以及标签胶囊本身,仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)拥有。

+ 1 - 1
.agents/skills/dsh-archive-agent-notes/SKILL.md

@@ -13,7 +13,7 @@ Read [the Agent Note rules](../../notes/README.md), [the archive instructions](.
 
 ## Check supersession when adding a note
 
-Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit.
+Before drafting, apply the [creation criteria](../../notes/README.md#when-to-write-one). Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit.
 
 ## Classify by future value
 

+ 1 - 1
AGENTS.md

@@ -147,7 +147,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
 - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)).
 - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
 - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
-- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
+- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
 - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
 - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
 - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)).

+ 1 - 1
apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md

@@ -1 +1 @@
-When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
+When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.

+ 81 - 0
apps/web/tests/markdown-file-links.e2e.ts

@@ -0,0 +1,81 @@
+/** Cold-replayed Markdown references open Session files through the shipped Web composition. */
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe('web e2e: Markdown file links', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await mkdir(join(scaffold.workspaceCwd, 'src'))
+    await mkdir(join(scaffold.workspaceCwd, 'other'))
+    await writeFile(join(scaffold.workspaceCwd, 'src/example.txt'),
+      Array.from({ length: 60 }, (_, i) => `source line ${i + 1}\n`).join(''))
+    await writeFile(join(scaffold.workspaceCwd, 'other/example.txt'), 'other file\n')
+    await seedSession(scaffold, await readFile(join(SNAPSHOT_DIR, 'session.v3.jsonl'), 'utf8'), 'markdown-file-links')
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.locator('[role="treeitem"]').first().click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).waitFor()
+    await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('previews relative and absolute links at the requested line without duplicate tabs', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-file-links'))
+    const source = page.getByRole('button', { name: 'src/example.txt:24–30', exact: true })
+    const prose = await source.locator('..').ariaSnapshot()
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'links.expected.md'), prose, MODE)
+    const beforeUrl = page.url()
+    await source.click()
+    const column = page.locator('[data-rightbar-col]')
+    await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n')
+    await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
+      .toBe(join(scaffold.workspaceCwd, 'src/example.txt'))
+    const absolute = page.getByRole('button', { name: 'src/example.txt:30', exact: true })
+    await absolute.focus()
+    await absolute.press('Enter')
+    await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('source line 30\n')
+    expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt'])
+    expect(page.url()).toBe(beforeUrl)
+    expect(page.context().pages()).toHaveLength(1)
+    expect(await page.getByRole('link', { name: 'Website', exact: true }).getAttribute('target')).toBe('_blank')
+    const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
+    await mkdir(artifacts, { recursive: true })
+    await page.screenshot({ path: join(artifacts, 'markdown-file-links.png'), animations: 'disabled' })
+    await page.getByRole('button', { name: 'other/example.txt', exact: true }).click()
+    await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
+      .toBe(join(scaffold.workspaceCwd, 'other/example.txt'))
+    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('other file\n')
+    expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt', 'example.txt'])
+    const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), preview, MODE)
+    await page.getByRole('button', { name: 'Missing file', exact: true }).click()
+    await column.locator('[data-textpreview-failed="workspace-file/not-found"]').waitFor()
+    expect(page.url()).toBe(beforeUrl)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md'])
+  })
+})

+ 6 - 0
apps/web/tests/settings-chrome.e2e.ts

@@ -126,6 +126,12 @@ describe('web e2e: settings modal and General preferences', () => {
     // presets took over included, preset compositions excluded.
     expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
       .toBe(expectedPluginCount)
+    // The enablement tag is the row's collapsed status: an active fiber draws no
+    // dot, so no global row names the active phase. Guard the assertion against
+    // matching nothing because no row is enabled.
+    expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]').count())
+      .toBeGreaterThan(0)
+    expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0)
     expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
       .toBe(String(expectedPluginCount))
     expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true')

+ 1 - 0
apps/web/tsconfig.json

@@ -90,6 +90,7 @@
     "tests/open-in-app-ssh.e2e.ts",
     "tests/message-feedback.e2e.ts",
     "tests/markdown-images.e2e.ts",
+    "tests/markdown-file-links.e2e.ts",
     "tests/reference-composer.e2e.ts",
     "tests/markdown-wide-table.e2e.ts",
     "tests/thinking-markdown.e2e.ts",

+ 1 - 1
docs/AGENTS.md

@@ -37,7 +37,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb
 ## Writing rules
 
 - **Document current state.** Keep history in commits, PRs, Agent Notes, postmortems, or scoped persistence records. Other prose names live mechanisms, not changes or stack positions. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence.
-- **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)).
+- **Apply the Agent Note creation criteria.** Mechanical/local edits are exempt, including local UI changes; keep existing owning notes accurate ([scope](../.agents/notes/README.md#when-to-write-one)).
 - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
 - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
 - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)).

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: b5314413df8195834152c354df89c25600afc63b
-README.zh.md: 23288907f6f0dd4dec457ac56e403b050b6ef4e5
+README.md: e7d3efe37e089f4b7bcbb7d954e6791899cebfc0
+README.zh.md: 8b49332ad39d373fcc2aae66af2e7c284bfee9bb

+ 2 - 0
packages/client/ui-chat/README.md

@@ -29,6 +29,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 <a id="reference-previews"></a>
 ## Reference previews
 
+Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
+
 HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
 
 <a id="system-prompt-row"></a>

+ 2 - 0
packages/client/ui-chat/README.zh.md

@@ -29,6 +29,8 @@ kind: "package-reference"
 <a id="reference-previews"></a>
 ## 引用预览
 
+Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
+
 Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
 
 <a id="system-prompt-row"></a>

+ 1 - 1
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -782,7 +782,7 @@ export function ChatView({
               </button>
             </div>
           )}
-          <MarkdownDelegateProvider openExternalLink={openExternalLink}>
+          <MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
             <ChatNodeList
               order={order}
               useChatNode={useChatNode}

+ 10 - 0
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -506,6 +506,16 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
 
 describe('Chat node rendering', () => {
 
+  it('opens Markdown references to unmodified files with line navigation', () => {
+    const h = makeHarness({
+      nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)],
+      turnEnds: new Map([[1, 2]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(h.openFile).toHaveBeenCalledWith('src/index.ts', { line: 24 })
+  })
+
   it('threads the injected file-mention vocabulary into the closing prose only', () => {
     const wrote = (seq: number, callId: string): ToolResultNode => ({
       ...toolResult(seq, callId, 'write'),

+ 2 - 2
packages/client/ui-deliverables/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-deliverables/README.md
-README.md: 125c84c91553b93e97f9158c9429014051804ed9
-README.zh.md: ef5b7e97f90f1101e765c776a0c1a90dfe7c1319
+README.md: 46e289ddb572928baaee7ac1a370087fd789aebe
+README.zh.md: 804ee5c7613510df96ac48cfe08f9b0fd39f7bbc

+ 4 - 4
packages/client/ui-deliverables/README.md

@@ -54,7 +54,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost.
+The Node half registers the static `ui:deliverable-file-references` system-prompt section described under [Model Experience](#model-experience). Explicit Markdown links use the shared [Markdown renderer](../ui-primitives/README.md); inline-code matching remains limited to produced or delivered files. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost.
 
 Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
@@ -83,11 +83,11 @@ Read these pages when the deliverables surface is not enough. They move from the
 
 #### What the model sees
 
-One fixed paragraph instructs the model to name primary files from successful creation or modification calls in its final response and to format those and any other changed-file references as exact-path or unique-basename Markdown inline code, such as `out/report.html`.
+The guidance asks the model to name primary outputs after successful creation or modification and link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`.
 
 #### Token effect
 
-One fixed prompt paragraph whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text.
+One fixed paragraph containing an output reminder and file-reference guidance whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text.
 
 #### KV Cache effect
 
@@ -101,7 +101,7 @@ The section is static at first-party order 9000 for the lifetime of the package
 These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
-- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`.
+- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and inline-code references require `present`; explicit Markdown links can reference existing files directly.
 - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
 - **Native opening needs a Host desktop** — without one the review tab offers no native open; the comparison itself needs only the Host that recorded the turn.
 - **Comparisons are unhighlighted** — the tab shows plain hunks without syntax colouring, and draws at most 5,000 lines before saying so.

+ 4 - 4
packages/client/ui-deliverables/README.zh.md

@@ -54,7 +54,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。
+Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverable-file-references` 系统提示词段。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。
 
 原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
@@ -83,11 +83,11 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 #### 模型看到的内容
 
-一段固定提示词要求模型在最终回复中点名成功创建或修改的主要文件,并将这些文件以及正文中提到的其他本轮变更文件写成采用精确路径或唯一 basename 的 Markdown 行内代码,例如 `out/report.html`。
+提示词要求模型在成功创建或修改文件后点名主要产出,并链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。
 
 #### Token 影响
 
-加载本包时增加一段固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。
+加载本包时增加一段包含产出提醒和文件引用指导的固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。
 
 #### KV Cache 影响
 
@@ -101,7 +101,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`
+- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和行内代码引用需要调用 `present`;显式 Markdown 链接可以直接引用现有文件
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
 - **原生打开需要 Host 桌面**——没有桌面时 review tab 不提供原生打开;对比本身只需要记录了该轮的 Host。
 - **对比没有语法高亮**——tab 只显示纯文本 hunk,最多绘制 5000 行并给出提示。

+ 6 - 6
packages/client/ui-deliverables/src/index.ts

@@ -1,7 +1,6 @@
 /**
- * Deliverables plugin, node half. Registers the response-format guidance that
- * lets the browser half recognize final-response file references and serves
- * authenticated native opens of declared files. The browser
+ * Deliverables plugin, node half. Registers Web file-reference guidance and
+ * serves authenticated native opens of declared files. The browser
  * half ships via exports["./client"], discovered through the package.json
  * dsh.client declaration.
  */
@@ -14,12 +13,13 @@ import { registerPresentOpen } from './present-open.ts'
 /** Services required for file-reference guidance, change summaries, and authenticated native opens. */
 export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges']
 
-/** Stable final-response guidance owned by the matching renderer. */
+/** Static Web guidance for primary outputs and existing-file references. */
 const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. '
-  + 'To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.'
+  + 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. '
+  + 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.'
 
 /**
- * Register model guidance for the file-reference renderer shipped by this package.
+ * Register Web file-reference guidance and native opens for declared files.
  * @param ctx - host context carrying the system-prompt registry.
  */
 export function apply(ctx: Context): void {

+ 7 - 1
packages/client/ui-deliverables/tests/prompt.host.spec.ts

@@ -1,5 +1,6 @@
 /** Node-half coverage for the model guidance paired with Web file references. */
 
+import { readFile } from 'node:fs/promises'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -28,7 +29,12 @@ describe('ui-deliverables node plugin', () => {
 
     const section = (await ctx.systemPrompt.assemble()).sections
       .find(entry => entry.name === 'ui:deliverable-file-references')
-    expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn."')
+    expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."')
+
+    for (const scenario of ['cordis-tool-round', 'fresh-round-trip', 'ptc-round', 'schedule-catalog']) {
+      const sidecar = await readFile(new URL(`../../../../snapshots/web/${scenario}/system-prompt.expected.md`, import.meta.url), 'utf8')
+      expect(sidecar, scenario).toContain(section!.text)
+    }
 
     await mounted.dispose()
     expect((await ctx.systemPrompt.assemble()).sections

+ 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: d466de8e4a26a6476e373e19c45c72ae22197f1b
-README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6
+README.md: cefa67e49146d5fc2976d3d43b8ae39c3d4b99f6
+README.zh.md: 3c069d05b2948e8494cef934f87270e86a14d978

+ 2 - 0
packages/client/ui-primitives/README.md

@@ -72,6 +72,8 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
+The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
+
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
 `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.

+ 2 - 0
packages/client/ui-primitives/README.zh.md

@@ -72,6 +72,8 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
+最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
+
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。

+ 1 - 1
packages/client/ui-primitives/src/index.ts

@@ -69,7 +69,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx'
 export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
-export type { MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
+export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'

+ 27 - 10
packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx

@@ -1,5 +1,5 @@
-/** Consumer-owned navigation for ordinary Markdown HTTP(S) link activation. */
-import { createContext, useContext } from 'react'
+/** Consumer-owned navigation for Markdown links. */
+import { createContext, useContext, useMemo } from 'react'
 import type { ReactNode } from 'react'
 
 /**
@@ -8,31 +8,48 @@ import type { ReactNode } from 'react'
  */
 export type MarkdownExternalLinkHandler = (href: string) => void
 
-const MarkdownDelegateContext = createContext<MarkdownExternalLinkHandler | undefined>(undefined)
+/** Navigation capabilities supplied by the nearest Markdown owner. */
+export interface MarkdownDelegate {
+  /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
+  readonly openExternalLink?: MarkdownExternalLinkHandler | undefined
+  /**
+   * Open a decoded local destination from settled Markdown; absent handlers leave plain text.
+   * @param path - Absolute or workspace-relative file path.
+   * @param options - First line to reveal when the destination specifies a line or range.
+   */
+  readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined
+}
+
+const MarkdownDelegateContext = createContext<MarkdownDelegate>({})
 
 /** Props for one Markdown navigation scope. */
-export interface MarkdownDelegateProviderProps {
+export interface MarkdownDelegateProviderProps extends MarkdownDelegate {
   readonly children: ReactNode
-  readonly openExternalLink: MarkdownExternalLinkHandler
 }
 
 /**
- * Delegate ordinary Markdown HTTP(S) activation without threading callbacks through renderers.
- * @param props - child tree and its link handler.
+ * Scope Markdown navigation without threading callbacks through renderers.
+ * Nested providers replace the enclosing capabilities. Handler changes reach cached links.
+ * @param props - Child tree and its file and HTTP(S) link handlers.
  * @returns the scoped child tree.
  */
 export function MarkdownDelegateProvider({
   children,
   openExternalLink,
+  openFile,
 }: MarkdownDelegateProviderProps): ReactNode {
+  const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile])
   return (
-    <MarkdownDelegateContext.Provider value={openExternalLink}>
+    <MarkdownDelegateContext.Provider value={delegate}>
       {children}
     </MarkdownDelegateContext.Provider>
   )
 }
 
-/** Read the nearest optional Markdown HTTP(S) navigation delegate. */
-export function useMarkdownExternalLinkDelegate(): MarkdownExternalLinkHandler | undefined {
+/**
+ * Read the nearest Markdown navigation capabilities.
+ * @returns Owner callbacks, or an empty delegate outside a provider.
+ */
+export function useMarkdownDelegate(): MarkdownDelegate {
   return useContext(MarkdownDelegateContext)
 }

+ 9 - 2
packages/client/ui-primitives/src/markdown/MarkdownText.module.css

@@ -55,7 +55,8 @@
   margin-bottom: 8px;
 }
 
-.markdown a {
+.markdown a,
+.markdown .fileLink {
   /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
      keeps design-platform brand-text as near-black, so links use the
      dedicated link alias instead. */
@@ -80,7 +81,8 @@
   text-underline-offset: 3px;
 }
 
-.markdown a:focus-visible {
+.markdown a:focus-visible,
+.fileMention:focus-visible {
   box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
 }
 
@@ -314,6 +316,11 @@
   cursor: pointer;
 }
 
+.fileLink {
+  max-width: 100%;
+  text-align: inherit;
+}
+
 .fileMention:hover,
 .fileMention:focus {
   outline: none;

+ 5 - 3
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -165,9 +165,11 @@ class StreamingRenderer {
  * modified clicks retain native behavior. `variant="compact"` uses secondary
  * text sizing, uniform bold headings, and tight block spacing; the default
  * `body` variant uses the full document typography.
- * @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
- * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
- * images render directly.
+ * The provider's `openFile` enables local Markdown links in settled messages,
+ * including `#L24` and `#L24-L30` destinations (ranges open at their first line).
+ * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and
+ * unsafe protocols are disabled. Local links without an opener remain text;
+ * absolute HTTP(S) images render directly.
  */
 export const MarkdownText = memo(function MarkdownText({
   text, streaming = false, labels, fileMentions, pathImages, variant = 'body',

+ 32 - 0
packages/client/ui-primitives/src/markdown/file-link.ts

@@ -0,0 +1,32 @@
+/** Local Markdown destinations accepted by the file-preview callback. */
+
+/**
+ * Decode a file destination and its optional GitHub-style line fragment.
+ * Literal `?` and `#` in filenames must be percent-encoded.
+ * @param value - Parsed Markdown link destination.
+ * @returns A local path and optional first line, or undefined for URLs,
+ * fragment-only links, queries, malformed escapes, or invalid line ranges.
+ */
+export function parseFileLink(value: string): { path: string; line?: number } | undefined {
+  const hash = value.indexOf('#')
+  const destination = hash < 0 ? value : value.slice(0, hash)
+  if (destination.includes('?')) return undefined
+  let path: string
+  try {
+    path = decodeURIComponent(destination)
+  } catch (_error) {
+    // Malformed percent escapes cannot identify a file unambiguously.
+    return undefined
+  }
+  if (path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
+    || /^[\\/]{2}/.test(path)
+    || (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined
+  if (hash < 0) return { path }
+  const fragment = value.slice(hash + 1)
+  const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(fragment)
+  if (match === null) return undefined
+  const line = Number(match[1])
+  const end = match[2] === undefined ? line : Number(match[2])
+  if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined
+  return { path, line }
+}

+ 36 - 8
packages/client/ui-primitives/src/markdown/render.tsx

@@ -4,8 +4,8 @@
  * cache frozen blocks as React elements; the rendered DOM is pinned
  * byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
  *
- * Untrusted-output policy (unchanged from the replaced pipeline): link and
- * image destinations pass a protocol allowlist, images additionally require
+ * External link and image destinations pass a protocol allowlist; settled
+ * local file links use an explicit owner callback. Images additionally require
  * absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
  * DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
  * the allowlist, so footnote references and back-references render as plain
@@ -23,9 +23,10 @@ import type * as Md from 'mdast'
 import type {} from 'mdast-util-math'
 import { normalizeUri } from 'micromark-util-sanitize-uri'
 import { CodeBlock } from './CodeBlock.tsx'
+import { parseFileLink } from './file-link.ts'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
-import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx'
+import { useMarkdownDelegate } from './MarkdownDelegate.tsx'
 import type { PositionedBlock } from './incremental.ts'
 import css from './MarkdownText.module.css'
 
@@ -339,7 +340,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
     case 'table':
       return renderTable(node, key, context)
     case 'link':
-      return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key, !anchorWrapsOnlyImages(node.children))
+      return renderAnchor(
+        node.url, renderChildren(node.children, { ...context, inLink: true }), key,
+        !anchorWrapsOnlyImages(node.children), context.streaming,
+      )
     case 'linkReference':
       return renderLinkReference(node, key, context)
     case 'image':
@@ -538,7 +542,7 @@ function MarkdownAnchor({ href, glyph, children }: {
   readonly glyph: boolean
   readonly children: ReactNode[]
 }): ReactNode {
-  const openExternalLink = useMarkdownExternalLinkDelegate()
+  const { openExternalLink } = useMarkdownDelegate()
   const external = ['http:', 'https:'].includes(new URL(href).protocol)
   const open = external ? openExternalLink : undefined
   return (
@@ -557,11 +561,35 @@ function MarkdownAnchor({ href, glyph, children }: {
   )
 }
 
-/** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */
-function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true): ReactNode {
+/** Local destinations use the scoped file delegate after settlement. */
+function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, streaming = false): ReactNode {
+  const file = streaming ? undefined : parseFileLink(url)
+  if (file !== undefined) {
+    return <MarkdownFileLink key={key} file={file} glyph={glyph}>{children}</MarkdownFileLink>
+  }
   return renderSafeLink(normalizeUri(url), children, key, glyph)
 }
 
+function MarkdownFileLink({ file, glyph, children }: {
+  readonly file: { path: string; line?: number }
+  readonly glyph: boolean
+  readonly children: ReactNode[]
+}): ReactNode {
+  const { openFile } = useMarkdownDelegate()
+  if (openFile === undefined) return <>{children}</>
+  return (
+    <button
+      type="button"
+      className={clsx(css.fileMention, css.fileLink)}
+      title={file.path}
+      onClick={() => { openFile(file.path, file.line === undefined ? undefined : { line: file.line }) }}
+    >
+      {glyph && <LinkIcon kind={classifyLinkPath(file.path)} className={css.linkIcon} />}
+      {children}
+    </button>
+  )
+}
+
 /**
  * The complete inline-code value when it is exactly an absolute HTTP(S) URL
  * (no surrounding whitespace); anything else stays inert code.
@@ -623,7 +651,7 @@ function renderLinkReference(
     return <Fragment key={key}>{'['}{renderChildren(node.children, context)}{referenceSuffix(node)}</Fragment>
   }
   const rendered = renderChildren(node.children, { ...context, inLink: true })
-  return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children))
+  return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.streaming)
 }
 
 function renderImageReference(

+ 159 - 0
packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx

@@ -0,0 +1,159 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MarkdownDelegateProvider } from '../src/index.ts'
+import { MarkdownText } from './markdown-test-components.tsx'
+
+afterEach(cleanup)
+
+describe('Markdown file links', () => {
+  it.each([
+    ['src/index.ts', 'src/index.ts', undefined],
+    ['/workspace/src/index.ts#L24', '/workspace/src/index.ts', { line: 24 }],
+    ['../docs/My%20Notes.md#L24-L30', '../docs/My Notes.md', { line: 24 }],
+    ['C:/work/file.ts#L2', 'C:/work/file.ts', { line: 2 }],
+    ['C:%5Cwork%5Cfile.ts', 'C:\\work\\file.ts', undefined],
+    ['docs/%E4%B8%AD%E6%96%87.md', 'docs/中文.md', undefined],
+    ['file%23name%3F.txt', 'file#name?.txt', undefined],
+  ])('opens %s through the preview callback', (target, path, options) => {
+    const openFile = vi.fn()
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile}>
+        <MarkdownText text={`[source](${target})`} />
+      </MarkdownDelegateProvider>,
+    )
+    const link = view.getByRole('button', { name: 'source' })
+    expect(link.getAttribute('title')).toBe(path)
+    fireEvent.click(link)
+    expect(openFile).toHaveBeenCalledWith(path, options)
+    expect(view.container.querySelector('a')).toBeNull()
+  })
+
+  it.each(['', '![](https://example.com/image.png)'])('names an empty label %s with the decoded path', (label) => {
+    const view = render(
+      <MarkdownDelegateProvider openFile={vi.fn()}>
+        <MarkdownText text={`[${label}](docs/My%20Notes.md)`} />
+      </MarkdownDelegateProvider>,
+    )
+    expect(view.getByRole('button', { name: 'docs/My Notes.md' })).toBeTruthy()
+  })
+
+  it('preserves an image label’s alternative text as the accessible name', () => {
+    const view = render(
+      <MarkdownDelegateProvider openFile={vi.fn()}>
+        <MarkdownText text={'[![diagram](https://example.com/image.png)](src/a.ts)'} />
+      </MarkdownDelegateProvider>,
+    )
+    expect(view.getByRole('button', { name: 'diagram' })).toBeTruthy()
+  })
+
+  it('handles reference links and code labels without nesting file-mention buttons', () => {
+    const openFile = vi.fn()
+    const resolve = vi.fn()
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile}>
+        <MarkdownText
+          text={'[see `index.ts`][source]\n\n[source]: src/index.ts#L3-L3'}
+          fileMentions={{ resolve }}
+        />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(view.getByRole('button', { name: 'see index.ts' }))
+    expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 })
+    expect(resolve).not.toHaveBeenCalled()
+    expect(view.container.querySelector('button button')).toBeNull()
+  })
+
+  it.each([
+    '//example.com/file', '%2F%2Fexample.com/file', '%5C%5Cserver%5Cfile',
+    'javascript:alert', 'data:text/plain,hi', 'file:///etc/passwd', 'vscode:open',
+    '%6Aavascript:alert', '#L2', 'file.ts?raw=1', 'file%ZZ.ts', 'file%00.ts',
+    'file.ts#heading', 'file.ts#L0', 'file.ts#L3-L2',
+    'file.ts#L9007199254740992', 'file.ts#L1-L9007199254740992',
+  ])('keeps unsupported destination %s inert', (target) => {
+    const openFile = vi.fn()
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile}>
+        <MarkdownText text={`[source](${target})`} />
+      </MarkdownDelegateProvider>,
+    )
+    expect(view.getByText('source')).toBeTruthy()
+    expect(view.container.querySelector('button, a')).toBeNull()
+    expect(openFile).not.toHaveBeenCalled()
+  })
+
+  it('preserves external links and leaves local links inert without an opener', () => {
+    const view = render(<MarkdownText text={'[web](https://example.com/a#L1) [mail](mailto:a@example.com) [file](src/a.ts)'} />)
+    expect(view.getByRole('link', { name: 'web' }).getAttribute('target')).toBe('_blank')
+    expect(view.getByRole('link', { name: 'mail' }).getAttribute('href')).toBe('mailto:a@example.com')
+    expect(view.queryByRole('button')).toBeNull()
+  })
+
+  it('enables links after streaming and uses a replaced opener', () => {
+    const first = vi.fn()
+    const second = vi.fn()
+    const text = '[source](src/a.ts)\n\nmore\n\n'
+    const view = render(
+      <MarkdownDelegateProvider openFile={first}>
+        <MarkdownText text={text} streaming />
+      </MarkdownDelegateProvider>,
+    )
+    expect(view.queryByRole('button')).toBeNull()
+    view.rerender(
+      <MarkdownDelegateProvider openFile={first}>
+        <MarkdownText text={text} />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(first).toHaveBeenCalledOnce()
+    view.rerender(
+      <MarkdownDelegateProvider openFile={second}>
+        <MarkdownText text={text} />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(second).toHaveBeenCalledOnce()
+    expect(first).toHaveBeenCalledOnce()
+  })
+
+  it('routes file and HTTP(S) links through the same provider', () => {
+    const openFile = vi.fn()
+    const openExternalLink = vi.fn()
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile} openExternalLink={openExternalLink}>
+        <MarkdownText text={'[source](src/a.ts#L4) [web](https://example.com)'} />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(openFile).toHaveBeenCalledExactlyOnceWith('src/a.ts', { line: 4 })
+    expect(openExternalLink).not.toHaveBeenCalled()
+    fireEvent.click(view.getByRole('link', { name: 'web' }))
+    expect(openExternalLink).toHaveBeenCalledExactlyOnceWith('https://example.com')
+    expect(openFile).toHaveBeenCalledOnce()
+  })
+
+  it('updates cached links when the nearest provider gains, replaces, or removes its file handler', () => {
+    const outer = vi.fn()
+    const first = vi.fn()
+    const second = vi.fn()
+    const markdown = <MarkdownText text="[source](src/a.ts)" />
+    const scope = (openFile?: (path: string) => void) => (
+      <MarkdownDelegateProvider openFile={outer}>
+        <MarkdownDelegateProvider openFile={openFile}>{markdown}</MarkdownDelegateProvider>
+      </MarkdownDelegateProvider>
+    )
+    const view = render(scope())
+    expect(view.queryByRole('button')).toBeNull()
+    view.rerender(scope(first))
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(first).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined)
+    view.rerender(scope(second))
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(second).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined)
+    expect(first).toHaveBeenCalledOnce()
+    view.rerender(scope())
+    expect(view.queryByRole('button')).toBeNull()
+    expect(view.getByText('source')).toBeTruthy()
+    expect(outer).not.toHaveBeenCalled()
+  })
+})

+ 2 - 2
packages/client/ui-settings-plugin-inventory/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-plugin-inventory/README.md
-README.md: a82f177b2a0a463a395cef470b526ea3117625fd
-README.zh.md: c6395d015a7c60b0b0738eae96ae99fe1135cedc
+README.md: 9b41c7490055dcf9865958a4e243b115fdbd40b8
+README.zh.md: dd4b1ebcd68a48573c42864eb796c58b49f9b834

+ 1 - 1
packages/client/ui-settings-plugin-inventory/README.md

@@ -31,7 +31,7 @@ A failed installation with pending pnpm build permissions offers **Allow these s
 
 ### Reading a card
 
-Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; enabled entries also show a colored root-fiber status dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.
+Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; a colored root-fiber status dot marks only the `pending`, `loading`, and `unloading` phases the tag cannot state, so an `active` fiber shows its tag alone. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.
 
 ### The preset switcher
 

+ 1 - 1
packages/client/ui-settings-plugin-inventory/README.zh.md

@@ -31,7 +31,7 @@ kind: "package-reference"
 
 ### 阅读卡片
 
-每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
+每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;彩色根 fiber 状态圆点只标记标签无法表达的 `pending`、`loading`、`unloading` 阶段,`active` 的条目只显示标签。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
 
 ### 预设切换器
 

+ 17 - 9
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -164,19 +164,27 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: {
   )
 }
 
-/* `pending` is the only phase with no work under way. `loading` and
+/* `pending` is the only dotted phase with no work under way. `loading` and
  * `unloading` are both live transitions the Host is running — an async
- * disposer can hold `unloading` for a while — so both animate. */
+ * disposer can hold `unloading` for a while — so both animate. `active` and
+ * `failed` carry no dot: a settled enabled row shows its enablement tag alone,
+ * and a failed row has the failure tag. */
 const PHASE_DOT_STATES = {
   pending: 'idle',
   loading: 'ongoing',
-  active: 'done',
-  failed: 'error',
   unloading: 'ongoing',
-} as const satisfies Record<NonNullable<PluginFiberPhase>, StateDotState>
+} as const satisfies Partial<Record<NonNullable<PluginFiberPhase>, StateDotState>>
 
-/** Status dot naming a live root-fiber phase; rows with no live fiber show none. */
-function PhaseDot({ phase, t }: { readonly phase: NonNullable<PluginFiberPhase>; readonly t: Translate }): ReactNode {
+/** A live root-fiber phase whose dot still adds to the row's enablement tag. */
+type DotPhase = keyof typeof PHASE_DOT_STATES
+
+/** Whether a live root-fiber phase carries a dot of its own. */
+function showsPhaseDot(phase: PluginFiberPhase): phase is DotPhase {
+  return phase === 'pending' || phase === 'loading' || phase === 'unloading'
+}
+
+/** Status dot naming a live root-fiber phase; rows without a dotted phase show none. */
+function PhaseDot({ phase, t }: { readonly phase: DotPhase; readonly t: Translate }): ReactNode {
   const status = phaseLabel(phase, t)
   /* StateDot is aria-hidden, so the phase name lives on this wrapper. */
   return (
@@ -301,7 +309,7 @@ export function PluginInventorySettingsTab(
         ariaLabel={`${title}${row.entryId === null ? '' : `, ${row.entryId}`}, ${stateText}`}
         trailing={(
           <>
-            {row.enabled === true && !failed && row.fiberPhase !== null
+            {row.enabled === true && showsPhaseDot(row.fiberPhase)
               ? <PhaseDot phase={row.fiberPhase} t={t} />
               : null}
             <StateTag kind={kind} label={stateText} />
@@ -347,7 +355,7 @@ export function PluginInventorySettingsTab(
         ariaLabel={`${title}, ${entry.entryId}, ${stateText}`}
         trailing={(
           <>
-            {entry.enabled && !failed && entry.fiberPhase !== null
+            {entry.enabled && showsPhaseDot(entry.fiberPhase)
               ? <PhaseDot phase={entry.fiberPhase} t={t} />
               : null}
             <StateTag kind={kind} label={stateText} />

+ 33 - 3
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -116,9 +116,10 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.getByText(en.conditionalTag)).toBeTruthy()
     expect(screen.getByText(en.disabledTag)).toBeTruthy()
     expect(screen.getByText(en.failedTag)).toBeTruthy()
-    expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy()
-    // No live fiber, no dot: file-state rows carry only their enablement tag.
-    expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull()
+    // The enablement tag is the row's one settled status signal: an active fiber
+    // and a row with no live fiber both render without a phase dot.
+    expect(screen.queryByRole('img', { name: en.active })).toBeNull()
+    expect(screen.queryByRole('img', { name: en.unobserved })).toBeNull()
 
     expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
     expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7')
@@ -144,6 +145,35 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
   })
 
+  it('keeps the phase dot for a live phase the enablement tag does not state', async () => {
+    await renderReady({
+      entries: [
+        { entryId: 'booting', moduleName: '@fixture/booting', enabled: true, fiberPhase: 'loading' },
+        { entryId: 'waiting', moduleName: '@fixture/waiting', enabled: true, fiberPhase: 'pending' },
+        { entryId: 'running', moduleName: '@fixture/running', enabled: true, fiberPhase: 'active' },
+        { entryId: 'unobserved', moduleName: '@fixture/unobserved', enabled: true, fiberPhase: null },
+      ],
+      agentPresets: [{
+        id: 'standard',
+        trust: 'system',
+        isDefault: true,
+        rows: [
+          { entryId: 'stopping', moduleName: '@fixture/stopping', enabled: true, fiberPhase: 'unloading' },
+          { entryId: 'preset-running', moduleName: '@fixture/preset-running', enabled: true, fiberPhase: 'active' },
+        ],
+      }],
+    } as unknown as Snapshot)
+
+    fireEvent.click(globalToggle())
+    fireEvent.click(presetToggle())
+    expect(screen.getAllByText(en.enabledTag)).toHaveLength(6)
+    expect(screen.getByRole('img', { name: en.loadingPhase })).toBeTruthy()
+    expect(screen.getByRole('img', { name: en.pending })).toBeTruthy()
+    expect(screen.getByRole('img', { name: en.unloading })).toBeTruthy()
+    expect(screen.queryByRole('img', { name: en.active })).toBeNull()
+    expect(screen.queryByRole('img', { name: en.unobserved })).toBeNull()
+  })
+
   it('distinguishes collapsed same-module rows by stable entry id', async () => {
     const longId = 'include:agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
     const subtitle = 'agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'

+ 2 - 2
packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md
-README.md: 06b40006750cb6fb8cad614af9c275f9b7243e02
-README.zh.md: 8c49cfebb8cf79117c55150d8cc4b0de8ba3ac3a
+README.md: 168a30cd993c4b53a34785dd6f4e31bd0f007d8a
+README.zh.md: 42a96ffdd72efc6fcfee11eb973fa687021d6a0b

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
packages/deliverables/workspace-changes/README.md


+ 1 - 1
packages/deliverables/workspace-changes/README.zh.md

@@ -57,7 +57,7 @@ kind: "package-reference"
 
 每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;同一步骤还排入对 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用所指路径的整文件捕获,因此副本先于编辑,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。
 
-git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。
+git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_CONFIG_COUNT=0`(凭据清理会移除索引配置的键,因此不继承这些环境配置)、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。
 
 **运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要、快照树与捕获的副本;没有独立观察会与它们分歧。
 

+ 2 - 1
packages/deliverables/workspace-changes/src/git.ts

@@ -65,7 +65,8 @@ export class GitRunner {
       },
       graceMs: TERMINATE_GRACE_MS,
       signal,
-      env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env },
+      // The subprocess credential scrub removes ambient GIT_CONFIG_KEY_n entries.
+      env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env },
     })
     const outcome = await handle.done
     if (signal.aborted) {

+ 17 - 1
packages/deliverables/workspace-changes/tests/git.spec.ts

@@ -1,7 +1,7 @@
 /** Git command bounds, snapshot recovery, and diff failure reporting. */
 import { chmod, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import { GitRunner, blobText, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob } from '../src/git.ts'
@@ -29,6 +29,22 @@ async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024
 const signal = new AbortController().signal
 
 describe('GitRunner', () => {
+  it('ignores ambient indexed Git configuration after the credential scrub', async () => {
+    const cwd = await scratchDir('dsh-git-env-', cleanups)
+    git(cwd, 'init', '-q', '-b', 'main')
+    const { git: command } = await runner()
+    vi.stubEnv('GIT_CONFIG_COUNT', '1')
+    vi.stubEnv('GIT_CONFIG_KEY_0', 'core.bare')
+    vi.stubEnv('GIT_CONFIG_VALUE_0', 'true')
+    try {
+      const result = await command.run(['rev-parse', '--is-bare-repository'], { cwd, signal })
+      expect(result.exitCode, result.stderr).toBe(0)
+      expect(result.stdout.trim()).toBe('false')
+    } finally {
+      vi.unstubAllEnvs()
+    }
+  })
+
   it('reports timeouts and external aborts as failures', async () => {
     const cwd = await scratchDir('dsh-git-runner-', cleanups)
     const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 })

+ 8 - 5
packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts

@@ -27,10 +27,13 @@ async function waitForFile(path: string): Promise<void> {
   }
 }
 
-const listenersBefore = process.listenerCount('exit')
+const listenersBefore = new Set(process.listeners('exit'))
 const ctx = new Context()
 const fiber = await ctx.plugin(LocalSubprocessRuntime)
-const listenersAfterLoad = process.listenerCount('exit')
+const ownedListeners = process.listeners('exit').filter(listener => !listenersBefore.has(listener))
+// Independent process-lifetime listeners may be installed after this provider.
+const unrelatedListener = (): void => {}
+if (trigger === 'dispose') process.once('exit', unrelatedListener)
 if (kind === 'ordinary') {
   ctx.subprocess.spawn({
     argv: [process.execPath, managedTree, treeState],
@@ -63,9 +66,9 @@ await waitForFile(proceed)
 if (trigger === 'dispose') {
   await fiber.dispose()
   await writeFile(join(root, 'dispose.json'), JSON.stringify({
-    listenersBefore,
-    listenersAfterLoad,
-    listenersAfterDispose: process.listenerCount('exit'),
+    ownedListenersAfterLoad: ownedListeners.length,
+    ownedListenersAfterDispose: ownedListeners.filter(listener => process.listeners('exit').includes(listener)).length,
+    unrelatedListenerPreserved: process.listeners('exit').includes(unrelatedListener),
   }))
 } else if (trigger === 'direct') {
   process.exit(23)

+ 8 - 5
packages/subprocess/subprocess-local/tests/process-exit.spec.ts

@@ -118,9 +118,9 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) {
     treeGone = true
     const disposeCounts = trigger === 'dispose'
       ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as {
-        listenersBefore: number
-        listenersAfterLoad: number
-        listenersAfterDispose: number
+        ownedListenersAfterLoad: number
+        ownedListenersAfterDispose: number
+        unrelatedListenerPreserved: boolean
       }
       : undefined
     return { outcome, disposeCounts }
@@ -166,7 +166,10 @@ describe('synchronous cleanup on host exit', () => {
   it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: testTimeoutMs }, async () => {
     const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose')
     expect(outcome.exitCode).toBe(0)
-    expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1)
-    expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore)
+    expect(disposeCounts).toEqual({
+      ownedListenersAfterLoad: 1,
+      ownedListenersAfterDispose: 0,
+      unrelatedListenerPreserved: true,
+    })
   })
 })

+ 1 - 1
snapshots/web/cordis-tool-round/system-prompt.expected.md

@@ -136,7 +136,7 @@ Use subagent in the background by default. Start independent delegations togethe
 
 Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
 
-When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
+When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
 
 The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
 

+ 1 - 1
snapshots/web/fresh-round-trip/system-prompt.expected.md

@@ -30,7 +30,7 @@ Use subagent in the background by default. Start independent delegations togethe
 
 Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
 
-When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
+When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
 
 The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
 

+ 11 - 0
snapshots/web/markdown-file-links/links.expected.md

@@ -0,0 +1,11 @@
+- paragraph:
+  - button "src/example.txt:24–30"
+  - text: ·
+  - button "src/example.txt:30"
+  - text: ·
+  - button "other/example.txt"
+  - text: ·
+  - button "Missing file"
+  - text: ·
+  - link "Website":
+    - /url: https://example.com/

+ 6 - 0
snapshots/web/markdown-file-links/preview.expected.md

@@ -0,0 +1,6 @@
+- text: {{cwd}}/other/example.txt
+- button "Line wrap" [pressed]:
+  - img
+- button "Read the file again":
+  - img
+- text: other file

+ 10 - 0
snapshots/web/markdown-file-links/session.v3.jsonl

@@ -0,0 +1,10 @@
+{"version":3,"isSeeded":false,"createdAt":0,"cwd":"{{cwd}}","delegationDepth":0,"type":"session","id":"{{session:1}}"}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:1}}"}},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Link src/example.txt at lines 24–30 and line 30 (absolute path), other/example.txt, missing.txt, and https://example.com/."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
+{"type":"session/title","data":{"title":"File references","messageSeqs":[3],"source":{"kind":"fallback"}}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:3}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 7 - 0
snapshots/web/markdown-file-links/snapshot.yml

@@ -0,0 +1,7 @@
+version: 1
+scenario: markdown-file-links
+profile: web
+composition: web-default
+recording: authored
+header:
+  class: web-default

+ 1 - 1
snapshots/web/ptc-round/system-prompt.expected.md

@@ -540,7 +540,7 @@ declare const tools: {
 }
 ```
 
-When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
+When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
 
 The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
 

+ 1 - 1
snapshots/web/schedule-catalog/system-prompt.expected.md

@@ -34,4 +34,4 @@ Use subagent in the background by default. Start independent delegations togethe
 
 Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
 
-When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
+When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.

+ 1 - 0
tsconfig.host.json

@@ -83,6 +83,7 @@
     "apps/web/tests/open-in-app-ssh.e2e.ts",
     "apps/web/tests/message-feedback.e2e.ts",
     "apps/web/tests/markdown-images.e2e.ts",
+    "apps/web/tests/markdown-file-links.e2e.ts",
     "apps/web/tests/reference-composer.e2e.ts",
     "apps/web/tests/markdown-wide-table.e2e.ts",
     "apps/web/tests/thinking-markdown.e2e.ts",

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä