Bladeren bron

Merge latest master into fix/bundle-speed

07akioni 2 dagen geleden
bovenliggende
commit
d570f32f32
100 gewijzigde bestanden met toevoegingen van 874 en 178 verwijderingen
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml
  2. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
  3. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
  5. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
  6. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml
  8. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md
  9. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md
  10. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
  11. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
  12. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
  13. 2 2
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
  14. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
  15. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
  16. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
  17. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
  18. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
  19. 2 2
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml
  20. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md
  21. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md
  22. 6 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
  23. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
  24. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
  25. 1 1
      .github/AGENTS.md
  26. 42 2
      .github/workflows/ci-master.yml
  27. 29 12
      .github/workflows/ci.yml
  28. 3 1
      .github/workflows/expected-filenames.yml
  29. 10 1
      .github/workflows/sandbox.yml
  30. 3 0
      apps/web/tests/expected/skill-invocation-policy/preview.expected.md
  31. 6 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  32. 6 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  33. 80 3
      apps/web/tests/skill-invocation-policy.e2e.ts
  34. 24 1
      apps/web/tests/skill-user-invoke.e2e.ts
  35. 2 2
      docs/config-catalog.i18n.yaml
  36. 1 1
      docs/config-catalog.md
  37. 1 1
      docs/config-catalog.zh.md
  38. 2 2
      docs/event-producer-consumer.i18n.yaml
  39. 6 6
      docs/event-producer-consumer.md
  40. 6 6
      docs/event-producer-consumer.zh.md
  41. 2 2
      docs/subsystems/skills.i18n.yaml
  42. 2 4
      docs/subsystems/skills.md
  43. 2 4
      docs/subsystems/skills.zh.md
  44. 0 1
      package.json
  45. 2 2
      packages/api/session-controller/README.i18n.yaml
  46. 2 0
      packages/api/session-controller/README.md
  47. 2 0
      packages/api/session-controller/README.zh.md
  48. 1 0
      packages/api/session-controller/src/skill-catalog.ts
  49. 2 0
      packages/api/session-controller/src/types.ts
  50. 2 0
      packages/api/session-controller/tests/session-skills.host.spec.ts
  51. 2 2
      packages/boot/app-boot/README.i18n.yaml
  52. 1 1
      packages/boot/app-boot/README.md
  53. 1 1
      packages/boot/app-boot/README.zh.md
  54. 4 9
      packages/boot/app-boot/src/profile.ts
  55. 2 2
      packages/client/ui-chat/README.i18n.yaml
  56. 6 0
      packages/client/ui-chat/README.md
  57. 6 0
      packages/client/ui-chat/README.zh.md
  58. 4 2
      packages/client/ui-chat/package.json
  59. 6 0
      packages/client/ui-chat/src/client/apply.ts
  60. 3 2
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  61. 2 1
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  62. 5 3
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  63. 4 0
      packages/client/ui-chat/src/client/contract/slots.ts
  64. 16 0
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  65. 3 1
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  66. 3 1
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  67. 3 0
      packages/client/ui-chat/tsconfig.json
  68. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  69. 2 0
      packages/client/ui-conversation/README.md
  70. 2 0
      packages/client/ui-conversation/README.zh.md
  71. 6 0
      packages/client/ui-conversation/src/client/contract/input.ts
  72. 3 12
      packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css
  73. 2 1
      packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx
  74. 20 9
      packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
  75. 33 0
      packages/client/ui-conversation/src/client/input/editor/reference-activation.ts
  76. 4 12
      packages/client/ui-conversation/src/client/input/editor/text-ref.ts
  77. 3 0
      packages/client/ui-conversation/src/client/input/facade.ts
  78. 64 0
      packages/client/ui-conversation/tests/reference-activation.client.spec.ts
  79. 2 2
      packages/client/ui-input-trigger/README.i18n.yaml
  80. 2 0
      packages/client/ui-input-trigger/README.md
  81. 2 0
      packages/client/ui-input-trigger/README.zh.md
  82. 22 1
      packages/client/ui-input-trigger/src/client/controller.ts
  83. 8 1
      packages/client/ui-input-trigger/src/types.ts
  84. 23 0
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  85. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  86. 1 1
      packages/client/ui-primitives/README.md
  87. 1 1
      packages/client/ui-primitives/README.zh.md
  88. 1 1
      packages/client/ui-primitives/src/index.ts
  89. 38 10
      packages/client/ui-primitives/src/user-text.tsx
  90. 38 3
      packages/client/ui-primitives/tests/user-text.client.spec.tsx
  91. 2 2
      packages/client/ui-reference/README.i18n.yaml
  92. 2 0
      packages/client/ui-reference/README.md
  93. 2 0
      packages/client/ui-reference/README.zh.md
  94. 4 2
      packages/client/ui-reference/package.json
  95. 10 2
      packages/client/ui-reference/src/client/index.ts
  96. 18 1
      packages/client/ui-reference/tests/browser-plugin.client.spec.ts
  97. 3 0
      packages/client/ui-reference/tsconfig.json
  98. 2 2
      packages/client/ui-skill/README.i18n.yaml
  99. 2 0
      packages/client/ui-skill/README.md
  100. 2 0
      packages/client/ui-skill/README.zh.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
-2026-09-05-package-manifest-types.md: 94317a9317ba12059e726612840e4a001be2a892
-2026-09-05-package-manifest-types.zh.md: 2c40facd2591921123b5eae71b80c516f3b1f697
+2026-09-05-package-manifest-types.md: dc018ba019028b942a17cd016c8670f2405c3bc7
+2026-09-05-package-manifest-types.zh.md: 8fd323bfe6058a6a062e3834a8630c0181a203fc

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md

@@ -10,11 +10,11 @@ External packages need Harness manifest types without depending on boot or clien
 
 ## Decision
 
-[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished.
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. The [public package metadata decision](2026-09-10-public-package-manifest.md) owns the public field set and the separation from internal tool metadata.
 
 Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local.
 
-App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
+App-boot declares a production dependency because its published declarations reference the shared types. Client modules use a development dependency because their published APIs do not expose these types. Internal image-packer and Session catalog declarations stay with their readers. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
 
 ## Alternatives considered
 
@@ -29,3 +29,5 @@ App-boot declares a production dependency because its published declarations ref
 Authors gain one public import path at the cost of a published package and explicit dependency edges. Existing app-boot manifest type imports must use the new package. The [profile composition design](2026-08-05-profile-plugin-bundles.md) continues to own runtime semantics; type extraction does not change configuration acceptance or model-visible behavior.
 
 Compiler and packaged NodeNext consumer checks cover public imports. Existing profile, client, image configuration, and Session catalog tests cover reader behavior; documentation checks cover the utility classification and generated package catalogs. Optional declaration fields still require deliberate consumer updates when added.
+
+Manifest format and host compatibility declarations have no enforcement in current installers or loaders. The type-only package supplies neither a SemVer parser nor an installation policy; its README records that limitation so an author declaration is not mistaken for a compatibility check.

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md

@@ -10,11 +10,11 @@ Status: implemented
 
 ## 决策
 
-[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。[公共包元数据决策](2026-09-10-public-package-manifest.zh.md) 拥有公共字段范围及其与内部工具元数据的划分。
 
 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。
 
-App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
+App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块使用开发依赖,因为其发布 API 不暴露这些类型。内部镜像打包器和 Session 目录声明保留在各自读取方。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
 
 ## 考虑过的替代方案
 
@@ -29,3 +29,5 @@ App-boot 声明生产依赖,因为其发布的声明文件引用共享类型
 作者获得统一的公共导入路径,代价是一个发布包和明确的依赖边。已有的 app-boot manifest 类型导入需要改用新包。[Profile 组合设计](2026-08-05-profile-plugin-bundles.zh.md) 继续负责运行时语义;类型提取不改变配置接受范围或模型可见行为。
 
 编译器与打包后的 NodeNext 消费方检查覆盖公共导入。已有 profile、客户端、镜像配置和 Session 目录测试覆盖读取行为;文档检查覆盖工具库分类与生成的包目录。新增可选声明字段时,仍需主动更新消费方。
+
+当前安装器和加载器不强制检查 manifest 格式或宿主兼容性声明。纯类型包既不提供 SemVer 解析器,也不提供安装策略;其 README 记录此限制,避免将作者声明误认为兼容性检查。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml

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

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md

@@ -0,0 +1,35 @@
+# Agent Note: Public package manifest fields
+
+Status: implemented
+
+English | [中文](2026-09-10-public-package-manifest.zh.md)
+
+## Problem
+
+Plugin authors need npm identity, runtime requirements, and DSH declarations from one public import. Internal image-packaging, Session catalog, and generated proxy metadata do not define extension points for community plugins. Exposing those fields together makes internal mechanisms appear available to external authors.
+
+## Decision
+
+[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) describes the package.json fields DSH uses, with required `name` and `version`. Its optional `dsh` member uses `DshManifest` for public composition and author metadata. The type is a selected npm field set, not a complete package.json schema. App-boot adapts it with `Partial` for local profiles, which need no published identity.
+
+Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders.
+
+The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
+
+Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy.
+
+## Alternatives considered
+
+**Keep internal metadata in the public declaration.** A workspace-only migration catalog and an experimental image packer cannot offer public plugin behavior merely because their metadata is discoverable.
+
+**Put DSH compatibility under `dsh.engines`.** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) places its host requirement in top-level `engines.vscode`. Top-level `engines.dsh` gives authors one location for runtime requirements; DSH still owns enforcement of its custom key.
+
+**Use peer dependencies as the sole host requirement.** Peer dependencies constrain installed npm packages, including the CLI package `@deepseek-ai/dsh`. They do not identify the currently running DSH process when plugins live in a separate profile project.
+
+**Parse every domain through one mandatory parser.** Existing readers consume different subsets and own different errors and defaults. Combining them would make a client reader validate unrelated profile declarations. The public types remain independent of filesystem access and parsing policy.
+
+## Consequences
+
+External authors gain a complete package-level declaration and a smaller DSH author API. Consumers of removed internal types must use their owning implementations. The packer and repository catalog no longer depend on the public declaration package; app-boot retains a production dependency because its published profile type references it.
+
+Compiler and built NodeNext import checks verify required package identity, partial profiles, top-level engine declarations, and the absence of internal fields from the public API. Existing profile, packer, and Session catalog tests retain coverage of their accepted files and malformed declarations. No Session format, plugin loading rule, or model-visible behavior changes.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 公共 package manifest 字段
+
+Status: implemented
+
+[English](2026-09-10-public-package-manifest.md) | 中文
+
+## 问题
+
+插件作者需要从统一的公共导入路径获取 npm 身份、运行时要求和 DSH 声明。内部镜像打包、Session 目录和生成的代理元数据不定义社区插件扩展点。将这些字段一起暴露,会让外部作者误以为内部机制也可供使用。
+
+## 决策
+
+[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填。其可选的 `dsh` 成员使用 `DshManifest` 描述公共组合与作者元数据。该类型只选取所需 npm 字段,不是完整的 package.json schema(模式)。App-boot 通过 `Partial` 适配无需发布身份的本地 profile。
+
+运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。
+
+镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
+
+各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。
+
+## 考虑过的替代方案
+
+**将内部元数据保留在公共声明中。** 仅限工作区的迁移目录和实验性镜像打包器,不会因为其元数据可被发现就提供公共插件行为。
+
+**将 DSH 兼容性放在 `dsh.engines` 下。** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) 将宿主要求放在顶层 `engines.vscode`。顶层 `engines.dsh` 让作者在同一位置声明运行时要求;自定义键的检查仍由 DSH 负责。
+
+**仅用 peer dependency 声明宿主要求。** Peer dependency 约束已安装的 npm 包,包括 CLI 包 `@deepseek-ai/dsh`。插件位于独立 profile 项目时,它们无法标识当前运行的 DSH 进程。
+
+**通过统一的强制解析器解析所有领域。** 现有读取方消费不同字段子集,并各自拥有错误与默认值。合并它们会让客户端读取方校验无关的 profile 声明。公共类型保持独立于文件系统访问和解析策略。
+
+## 后果
+
+外部作者获得完整的包级声明和更小的 DSH 作者 API。已移除内部类型的消费方必须使用各自负责的实现。打包器与仓库目录不再依赖公共声明包;app-boot 保留生产依赖,因为其发布的 profile 类型引用该包。
+
+编译器和构建后的 NodeNext 导入检查验证包身份必填、部分 profile、顶层 engine 声明,以及公共 API 不含内部字段。现有 profile、打包器和 Session 目录测试继续覆盖其接受的文件与畸形声明。Session 格式、插件加载规则和模型可见行为均不改变。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml

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

+ 31 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md

@@ -0,0 +1,31 @@
+# Agent Note: Composer reference previews
+
+Status: implemented
+
+English | [中文](2026-09-10-composer-reference-previews.zh.md)
+
+## Problem
+
+Users need to inspect referenced files and skill instructions while composing a message and after sending it. File chips and editable slash tokens have different editing semantics, but both need recognizable preview gestures without changing what the next prompt sends.
+
+## Decision
+
+The [input-trigger source](../../../../packages/client/ui-input-trigger/README.md) owns optional reference activation. The editor routes atomic references by their source identity and editable tokens through the current source lexicon. File and skill sources open the existing right Sidebar file resource in the composing Session. Skill discovery retains the winning provider's optional instruction-file path, avoiding body loads and guesses based on skill names or directory conventions.
+
+The [composer](../../../../packages/client/ui-conversation/README.md) shares reference hover styles while preserving atomic file chips and editable `/name` text. Clicking does not serialize or submit the draft. Invalid chips, selection gestures, and unavailable source targets retain editor handling; virtual skills remain invocable without a file preview.
+
+Sent message bubbles retain their logged skill-invocation evidence for decoration. The [Chat target](../../../../packages/client/ui-chat/README.md) opens file paths in the viewed Session and routes loaded skill names through that Session's source. The shared user-text primitive renders these references as buttons with the existing prose file-link hover and focus style; it leaves session, directory, and command references inert.
+
+## Alternatives considered
+
+**Turning skill tokens into file chips** would change editing, clipboard, and prompt semantics to solve a presentation task. The existing editable token already identifies a skill through its source lexicon.
+
+**Resolving file and skill formats inside the composer** would couple the editor to provider catalog policy and preview services. Source-owned activation keeps those dependencies with the plugins that already own reference discovery.
+
+**Loading each skill body during discovery** would add work and provider side effects before the user requests a preview. Optional path metadata is sufficient for filesystem skills and preserves virtual providers.
+
+## Consequences
+
+Preview paths are transient discovery data, never added to Session messages. The skill plugin invalidates them with its existing per-Session catalog. An uncached click awaits the shared catalog fetch and retains its Session address; invalidation and disposal cancel pending previews. Filesystem providers publish resolved instruction paths while retaining discovered reload locators and resource bases. Sidebar resource readers retain responsibility for current contents, missing-file errors, and access policy. The [workspace source-file decision](2026-09-08-present-workspace-source-files.md) remains the owner of delivered-file behavior; composer previews do not supersede it.
+
+Focused tests cover source routing, disposal, invalidation, quoted paths, selection, and unchanged draft text. The real Web composition exercises both previews, equal hover backgrounds, and deletion after opening; owner-local expected output records the skill document and a sent message. A replayed skill-invocation turn verifies both sent references after reloading history.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 输入框引用预览
+
+Status: implemented
+
+[English](2026-09-10-composer-reference-previews.md) | 中文
+
+## Problem
+
+用户需要在编写消息时和发送后查看引用文件和 skill 指令。文件标签与可编辑的斜杠文本具有不同的编辑语义,但都需要明确的预览手势,且不能改变下一条提示发送的内容。
+
+## Decision
+
+[输入触发来源](../../../../packages/client/ui-input-trigger/README.zh.md)负责可选的引用激活。编辑器按来源身份路由原子引用,按来源当前词表路由可编辑文本。文件和 skill 来源在编写消息的 Session 中打开现有右侧栏文件资源。Skill 发现保留胜出提供方可选的指令文件路径,避免加载正文或根据 skill 名称、目录惯例猜测路径。
+
+[输入框](../../../../packages/client/ui-conversation/README.zh.md)共用引用悬停样式,同时保留原子文件标签和可编辑的 `/name` 文本。点击不序列化或提交草稿。无效标签、选择手势及不可用的来源目标仍由编辑器处理;虚拟 skill 仍可调用,但没有文件预览。
+
+已发送消息的气泡保留日志中的 skill 调用证据作为装饰依据。[Chat 目标](../../../../packages/client/ui-chat/README.zh.md)在当前查看的 Session 中打开文件路径,并通过该 Session 的来源路由已加载的 skill 名称。共享用户文本组件将这些引用渲染为按钮,复用现有正文文件链接的悬停和聚焦样式;会话、目录和命令引用不提供导航。
+
+## Alternatives considered
+
+**将 skill 文本转换为文件标签**会为了展示需求而改变编辑、剪贴板和提示语义。现有可编辑文本已经能够通过来源词表标识 skill。
+
+**在输入框内解析文件和 skill 格式**会让编辑器依赖提供方目录策略及预览服务。由来源负责激活,使这些依赖留在已经负责引用发现的插件中。
+
+**发现时加载每个 skill 的正文**会在用户请求预览之前增加工作和提供方副作用。可选路径元数据足以支持文件系统 skill,并保留虚拟提供方。
+
+## Consequences
+
+预览路径是临时发现数据,不会加入 Session 消息。Skill 插件使用现有的按 Session 缓存机制使路径失效。缓存未就绪时,点击等待共享目录请求并保留所属 Session 地址;缓存失效和插件释放会取消待处理的预览。文件系统提供方公布解析后的指令路径,同时保留发现时的重新加载定位信息和资源根。侧栏资源读取器继续负责当前内容、文件缺失错误和访问策略。[工作区源文件决策](2026-09-08-present-workspace-source-files.zh.md)仍负责交付文件行为;输入框预览不取代该决策。
+
+针对性测试覆盖来源路由、释放、缓存失效、带引号路径、文本选择和草稿不变。真实 Web 组合验证两种预览、一致的悬停背景及打开后的删除操作;其所属的预期输出记录 skill 文档及已发送消息。回放的 skill 调用轮次验证刷新历史后两种已发送引用仍可预览。

+ 2 - 2
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
-2026-07-21-serial-cross-platform-ci-reference.md: edb81b643d0cef2e5bc807005a9016324b8430ab
-2026-07-21-serial-cross-platform-ci-reference.zh.md: 41fd9c032038f2a312978acf995febfdab34aeaa
+2026-07-21-serial-cross-platform-ci-reference.md: 24022fea271d677a4588bd5dc9c7cb5b417ca8b7
+2026-07-21-serial-cross-platform-ci-reference.zh.md: c9fbc84d8ff91803acc6bcd008607fc03136b832

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md

@@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel
 
 Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
 
-The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
+The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation, the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under the `selfhosted` failover value, and Blacksmith's Windows runners under the `blacksmith` value (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md

@@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
 
 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
 
-当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
+当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 故障切换取值下使用自托管 `[self-hosted, dsh-win-ci, windows]` 池,在 `blacksmith` 取值下使用 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md);另见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
-2026-07-26-ci-failover-runbook.md: 559fa61bcf416bed3bd58b3ffcbc145038cdce22
-2026-07-26-ci-failover-runbook.zh.md: e2098b928b1a1f158bcbfabd940ca23a5cd0e28e
+2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b
+2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4

+ 5 - 5
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md

@@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
 
 ## Decision
 
-The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
+The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
 
 The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness.
 
@@ -32,9 +32,9 @@ The two switches are independent: flip only the one whose platform is degraded.
 
 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`.
 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
-3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
+3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
 
-**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.
+**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
 
 **Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so each switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variables only route work for members.
 
@@ -45,11 +45,11 @@ Capacity includes the master standby, main-CI jobs, and three release-rehearsal
 
 ### Switch back
 
-Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to anything other than `selfhosted`). New runs resolve back to their hosted pools. Remove any extra instances that were registered during the incident.
+Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to any value other than `selfhosted` or `blacksmith`). New runs resolve back to their hosted pools. Setting it to `blacksmith` keeps the jobs on Blacksmith until the value changes. Remove any extra instances that were registered during the incident.
 
 ### Trust boundary
 
-The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
+The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the `selfhosted` legs; the `blacksmith` legs carry no exclusion), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
 
 ## Alternatives considered
 

+ 5 - 5
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
+三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件在 `selfhosted` 取值下,Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
 
 [被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。
 
@@ -32,9 +32,9 @@ Status: implemented
 
 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。
 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
-3. 切换到此完成。Linux 故障切换状态下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
+3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
 
-**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。
+**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
 
 **谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此每个开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。
 
@@ -45,11 +45,11 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以
 
 ### 切回
 
-删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 外的任何值),新的运行即解析回各自的托管池。若故障期间追加注册过实例,将其移除。
+删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 与 `blacksmith` 之外的任何值),新的运行即解析回各自的托管池。设为 `blacksmith` 会让作业留在 Blacksmith,直到该值改变。若故障期间追加注册过实例,将其移除。
 
 ### 信任边界
 
-这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
+这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、Dependabot 由 `selfhosted` 腿排除;`blacksmith` 腿不带排除),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: 690f8e6f9b13fa7e72240a42ff482bd83f9088b0
-2026-08-08-native-windows-pull-request-ci.zh.md: 9efa3cbcf33b6c12e4eed253b6a0546c79b768fe
+2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5
+2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md

@@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions
 
 The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
 
-Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
+Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
 
 `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md

@@ -14,7 +14,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
 
 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
 
-每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
+每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
 
 `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
 

+ 2 - 2
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md
-2026-09-06-node-compatibility-selfhosted.md: c78092834123b837d100814be9beba52c1a41397
-2026-09-06-node-compatibility-selfhosted.zh.md: 6dcff8aa197c0995e4e90d2d56179340a41bc783
+2026-09-06-node-compatibility-selfhosted.md: c361d21d3e1093dd5c87bf2ba085bdd1acacb5d8
+2026-09-06-node-compatibility-selfhosted.zh.md: 80a9d8519d6084f5e01944b277882a51b2291e94

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md

@@ -10,7 +10,7 @@ The Node 22.19, 24.9, and 26 compatibility jobs consume hosted Linux minutes eve
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration.
+[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. The `blacksmith` failover value's branch drops those predicates: it targets ephemeral Blacksmith runners, so repository identity and fork status do not gate it (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
 
 The temporary tool cache trades repeated Node downloads for isolation across concurrent runners and Node versions. A setup-node-only [ESM preload](../../../../scripts/ci-compatible-toolcache.mjs) assigns the cache inside the action process: the Actions runner overwrites reserved environment variables after reading step configuration. An executed path check rejects installations outside runner temp; compatibility processes do not inherit the preload. pnpm keeps its existing private setup destination and persistent content-addressed store. Compile caches and node-gyp headers use runner temp before the first pnpm invocation. No global Node symlink or system package changes are introduced. Hosted jobs retain their tool and package caching; self-hosted jobs do not restore or upload hosted package caches. The runner owns temporary-directory cleanup between jobs, and the shared image supplies native npm packages’ compiler and Python prerequisites.
 

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。
+[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。`blacksmith` 故障切换取值下的分支放弃这些条件:它面向临时的 Blacksmith 运行器,因此仓库身份与 fork 状态不参与门控(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
 
 临时工具缓存以重复下载 Node 为代价,换取并发运行器与 Node 版本之间的隔离。仅用于 setup-node 的 [ESM 预加载模块](../../../../scripts/ci-compatible-toolcache.mjs) 在 action 进程内指定缓存:Actions 运行器在读取步骤配置后会覆盖保留的环境变量。实际执行的路径检查拒绝运行器临时目录之外的安装;兼容性进程不继承预加载设置。pnpm 保留现有的私有安装目录和持久化内容寻址 store。编译缓存与 node-gyp 头文件在首次调用 pnpm 前就使用运行器临时目录。不引入全局 Node 符号链接或系统软件包变更。托管作业保留其工具与软件包缓存;自托管作业不恢复或上传托管软件包缓存。运行器负责作业之间的临时目录清理,共享镜像提供原生 npm 软件包所需的编译器和 Python 前置依赖。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml

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

File diff suppressed because it is too large
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md


File diff suppressed because it is too large
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md


+ 1 - 1
.github/AGENTS.md

@@ -1,3 +1,3 @@
 # AGENTS.md — GitHub Actions
 
-Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches and Dependabot hosted fallback ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md)).
+Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).

+ 42 - 2
.github/workflows/ci-master.yml

@@ -281,7 +281,15 @@ jobs:
   # The named pools are restricted at the organization level to this repository.
   larger-runner-benchmark:
     if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
+    # Default measures the repository's own fleet tiers; under the matching
+    # platform's blacksmith failover value the tiers Blacksmith offers (up to
+    # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core
+    # rows keep the fleet labels because Blacksmith has no such tier.
+    runs-on: >-
+      ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+            || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith')
+          && matrix.blacksmith
+          || matrix.runner }}
     timeout-minutes: 15
     strategy:
       fail-fast: false
@@ -291,50 +299,62 @@ jobs:
           - platform: linux
             cores: '4'
             runner: dsh-ubuntu-24-04-4core
+            blacksmith: blacksmith-4vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '8'
             runner: dsh-ubuntu-24-04-8core
+            blacksmith: blacksmith-8vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '16'
             runner: dsh-ubuntu-24-04-16core
+            blacksmith: blacksmith-16vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '32'
             runner: dsh-ubuntu-24-04-32core
+            blacksmith: blacksmith-32vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '64'
             runner: dsh-ubuntu-24-04-64core
+            blacksmith: ''
             workload: typecheck
           - platform: linux
             cores: '96'
             runner: dsh-ubuntu-24-04-96core
+            blacksmith: ''
             workload: typecheck
           - platform: windows
             cores: '4'
             runner: dsh-windows-2025-4core
+            blacksmith: blacksmith-4vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '8'
             runner: dsh-windows-2025-8core
+            blacksmith: blacksmith-8vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '16'
             runner: dsh-windows-2025-16core
+            blacksmith: blacksmith-16vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '32'
             runner: dsh-windows-2025-32core
+            blacksmith: blacksmith-32vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '64'
             runner: dsh-windows-2025-64core
+            blacksmith: ''
             workload: production-site
           - platform: windows
             cores: '96'
             runner: dsh-windows-2025-96core
+            blacksmith: ''
             workload: production-site
     steps:
       - uses: actions/checkout@v6
@@ -372,7 +392,15 @@ jobs:
   # Windows runs both blocking build targets concurrently through run-gates.
   consolidated-runner-benchmark:
     if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
+    # Default measures the repository's own fleet tiers; under the matching
+    # platform's blacksmith failover value the tiers Blacksmith offers (up to
+    # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core
+    # rows keep the fleet labels because Blacksmith has no such tier.
+    runs-on: >-
+      ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+            || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith')
+          && matrix.blacksmith
+          || matrix.runner }}
     timeout-minutes: 15
     strategy:
       fail-fast: false
@@ -382,50 +410,62 @@ jobs:
           - platform: linux
             cores: '4'
             runner: dsh-ubuntu-24-04-4core
+            blacksmith: blacksmith-4vcpu-ubuntu-2404
             workers: '4'
           - platform: linux
             cores: '8'
             runner: dsh-ubuntu-24-04-8core
+            blacksmith: blacksmith-8vcpu-ubuntu-2404
             workers: '8'
           - platform: linux
             cores: '16'
             runner: dsh-ubuntu-24-04-16core
+            blacksmith: blacksmith-16vcpu-ubuntu-2404
             workers: '16'
           - platform: linux
             cores: '32'
             runner: dsh-ubuntu-24-04-32core
+            blacksmith: blacksmith-32vcpu-ubuntu-2404
             workers: '32'
           - platform: linux
             cores: '64'
             runner: dsh-ubuntu-24-04-64core
+            blacksmith: ''
             workers: '32'
           - platform: linux
             cores: '96'
             runner: dsh-ubuntu-24-04-96core
+            blacksmith: ''
             workers: '32'
           - platform: windows
             cores: '4'
             runner: dsh-windows-2025-4core
+            blacksmith: blacksmith-4vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '8'
             runner: dsh-windows-2025-8core
+            blacksmith: blacksmith-8vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '16'
             runner: dsh-windows-2025-16core
+            blacksmith: blacksmith-16vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '32'
             runner: dsh-windows-2025-32core
+            blacksmith: blacksmith-32vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '64'
             runner: dsh-windows-2025-64core
+            blacksmith: ''
             workers: '2'
           - platform: windows
             cores: '96'
             runner: dsh-windows-2025-96core
+            blacksmith: ''
             workers: '2'
     steps:
       - uses: actions/checkout@v6

+ 29 - 12
.github/workflows/ci.yml

@@ -31,7 +31,10 @@ jobs:
   # repository state — not PR-editable, no merge required) retargets all
   # three onto the in-house
   # vm-backup pool and re-running the failed jobs is the entire switch —
-  # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
+  # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md.
+  # Setting the variable to 'blacksmith' instead routes the same jobs onto
+  # Blacksmith's hosted runners at the matching vCPU size (see
+  # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The
   # in-house pool's readiness is re-proven on every master push by the
   # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
   # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native
@@ -39,7 +42,8 @@ jobs:
   node-24:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -105,7 +109,8 @@ jobs:
   node-24-coverage:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -226,7 +231,8 @@ jobs:
   node-24-consumers:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -327,9 +333,12 @@ jobs:
 
   node-compat:
     if: github.event_name == 'pull_request'
-    # This job admits only repository-owned PR code to the persistent shared VM.
+    # Under the selfhosted leg this job admits only repository-owned PR code
+    # to the persistent shared VM; the blacksmith branch targets ephemeral
+    # runners and carries none of those predicates.
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.head.repo.full_name == github.repository
           && github.event.pull_request.head.repo.fork == false
           && github.event.pull_request.user.login != 'dependabot[bot]'
@@ -451,7 +460,8 @@ jobs:
   windows-build:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -495,7 +505,8 @@ jobs:
   windows-coverage:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -571,7 +582,8 @@ jobs:
   windows-native-tests:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -620,7 +632,8 @@ jobs:
     if: github.event_name == 'pull_request'
     continue-on-error: true
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -675,9 +688,13 @@ jobs:
     # the worker jobs it aggregates, so a standard-hosted outage cannot strand
     # the branch-protection verdict either. It retargets with the Linux switch
     # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the
-    # required Linux workers and runs on the vm-backup pool.
+    # required Linux workers and runs on the vm-backup pool. Under the
+    # 'blacksmith' value the verdict shares Blacksmith's pool with its workers
+    # through the same selector, so a Blacksmith outage strands both together —
+    # the accepted consequence of the explicit opt-in switch.
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'ubuntu-latest' }}

+ 3 - 1
.github/workflows/expected-filenames.yml

@@ -18,7 +18,9 @@ env:
 jobs:
   expected-filenames:
     name: no golden filenames
-    runs-on: ubuntu-latest
+    runs-on: >-
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || 'ubuntu-latest' }}
     steps:
       - uses: actions/checkout@v6
 

+ 10 - 1
.github/workflows/sandbox.yml

@@ -44,6 +44,12 @@ jobs:
       fail-fast: false
       matrix:
         include:
+          # Only the bwrap leg has a Blacksmith equivalent: the earlier
+          # migration dispatch runs measured that Blacksmith's Linux images do
+          # not enforce Landlock (the run-guard would turn the leg red) and
+          # its macOS image is unverified, so those legs stay on GitHub's
+          # images under every failover value — see
+          # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md.
           - os: ubuntu-latest
             runner: bwrap
           - os: ubuntu-24.04
@@ -53,7 +59,10 @@ jobs:
           - os: macos-latest
             runner: seatbelt
     name: sandbox e2e (${{ matrix.runner }}, ${{ matrix.os }})
-    runs-on: ${{ matrix.os }}
+    runs-on: >-
+      ${{ matrix.runner == 'bwrap' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+          && 'blacksmith-4vcpu-ubuntu-2404'
+          || matrix.os }}
     timeout-minutes: 20
     steps:
       - uses: actions/checkout@v6

+ 3 - 0
apps/web/tests/expected/skill-invocation-policy/preview.expected.md

@@ -0,0 +1,3 @@
+- separator
+- 'heading "name: policy-shared description: Available to both model and user invocation" [level=2]'
+- heading "policy-shared" [level=1]

+ 6 - 2
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -1,6 +1,6 @@
 - banner:
   - navigation "Session hierarchy":
-    - button "/user-invoke-demo and confirm the fixtur" [disabled]
+    - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled]
   - img
   - text: Standard mode
   - button "More actions":
@@ -14,7 +14,11 @@
   - img
   - img
   - text: System prompt
-- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
+- button "/user-invoke-demo"
+- button "meeting notes.md":
+  - img
+  - text: meeting notes.md
+- text: and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
 - button "Thought for a while" [expanded]:

+ 6 - 2
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -1,6 +1,6 @@
 - banner:
   - navigation "Session hierarchy":
-    - button "/user-invoke-demo and confirm the fixtur" [disabled]
+    - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled]
   - img
   - text: Standard mode
   - button "More actions":
@@ -14,7 +14,11 @@
   - img
   - img
   - text: System prompt
-- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
+- button "/user-invoke-demo"
+- button "meeting notes.md":
+  - img
+  - text: meeting notes.md
+- text: and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
 - button "Thought for a while":

+ 80 - 3
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -3,7 +3,7 @@
 // with their marker while user-disabled quadrants stay hidden. A real
 // chromium connects a fresh workspace seeded with all four policy quadrants;
 // no model call is issued, so a stray stream fails loud on the open LLM seam.
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -56,7 +56,8 @@ const SKILLS: readonly SeedSkill[] = [
 
 async function seedSkills(workspaceCwd: string): Promise<void> {
   for (const skill of SKILLS) {
-    const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
+    const root = join(workspaceCwd, 'workspace', '.agents', 'skills')
+    const directory = skill.name === 'policy-shared' ? join(workspaceCwd, 'linked-skills', skill.name) : join(root, skill.name)
     await mkdir(directory, { recursive: true })
     const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
     await writeFile(join(directory, 'SKILL.md'), [
@@ -69,6 +70,10 @@ async function seedSkills(workspaceCwd: string): Promise<void> {
       `# ${skill.name}`,
       '',
     ].join('\n'))
+    if (skill.name === 'policy-shared') {
+      await mkdir(root, { recursive: true })
+      await symlink(join(directory, 'SKILL.md'), join(root, `${skill.name}.md`))
+    }
   }
 }
 
@@ -81,6 +86,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
     await seedSkills(scaffold.workspaceCwd)
+    await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting-notes.md'), '# Meeting notes\n\nReference preview fixture.\n')
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -122,6 +128,77 @@ describe('web e2e: skill invocation policy through the real host', () => {
     await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md', 'preview.expected.md'])
+  })
+
+  it('opens skill and file references beside the unchanged draft with matching hover backgrounds', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-preview'))
+    const input = page.locator('[data-composer-input]').first()
+    await writeComposerDraft(page, input, '/policy-shared hello @meeting-notes')
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    const option = menu.getByRole('option', { name: /meeting-notes\.md/ })
+    await option.click()
+    const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' })
+    const file = input.locator('[data-composer-chip]')
+    const draft = await input.textContent()
+    const alignment = await input.evaluate((el) => {
+      const skill = el.querySelector<HTMLElement>('[data-composer-text-ref]')!
+      const chip = el.querySelector<HTMLElement>('[data-composer-chip] span')!
+      const plain = el.querySelector<HTMLElement>('[data-lexical-text]:not([data-composer-text-ref])')!
+      const textTop = (element: Element): number => {
+        const range = el.ownerDocument.createRange()
+        range.selectNodeContents(element)
+        return range.getBoundingClientRect().top
+      }
+      return {
+        skillTop: skill.getBoundingClientRect().top,
+        fileTop: chip.getBoundingClientRect().top,
+        skillHeight: skill.getBoundingClientRect().height,
+        fileHeight: chip.getBoundingClientRect().height,
+        skillTextTop: textTop(skill),
+        fileTextTop: textTop(chip.lastElementChild!),
+        plainTextTop: textTop(plain),
+      }
+    })
+    expect(alignment.fileHeight).toBeCloseTo(alignment.skillHeight, 0)
+    expect(alignment.fileTop).toBeCloseTo(alignment.skillTop, 0)
+    expect(alignment.fileTextTop).toBeCloseTo(alignment.plainTextTop, 0)
+    expect(alignment.skillTextTop).toBeCloseTo(alignment.plainTextTop, 0)
+    await skill.hover()
+    const skillBackground = await skill.evaluate(el => getComputedStyle(el).backgroundColor)
+    expect(skillBackground).not.toBe('rgba(0, 0, 0, 0)')
+    await skill.click()
+    const preview = page.locator('[data-document-markdown]')
+    await expect.poll(() => preview.textContent()).toContain('policy-shared')
+    expect(await input.textContent()).toBe(draft)
+    const snapshot = await captureStableAria(page, '[data-document-markdown]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), snapshot, MODE)
+    await file.hover()
+    expect(await file.locator('span').first().evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
+    await file.click()
+    await expect.poll(() => preview.textContent()).toContain('Reference preview fixture.')
+    expect(await input.textContent()).toBe(draft)
+    await file.hover()
+    await skill.dblclick()
+    await expect.poll(() => preview.textContent()).toContain('policy-shared')
+    await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).not.toBe('')
+    expect(await input.textContent()).toBe(draft)
+    await skill.hover()
+    await expect.poll(() => skill.evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    // Establish the deletion caret before the key event; Chromium delivers
+    // native selectionchange asynchronously after pointer and arrow actions.
+    await input.evaluate((el) => {
+      el.focus()
+      const selection = el.ownerDocument.getSelection()!
+      selection.selectAllChildren(el)
+      selection.collapseToEnd()
+      el.ownerDocument.dispatchEvent(new Event('selectionchange'))
+    })
+    await page.keyboard.press('Backspace')
+    await page.keyboard.press('Backspace')
+    await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
+    expect(await input.textContent()).toContain('/policy-shared')
   })
 })

+ 24 - 1
apps/web/tests/skill-user-invoke.e2e.ts

@@ -30,7 +30,7 @@ const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 const SKILL_NAME = 'user-invoke-demo'
-const ARGS_TEXT = 'and confirm the fixture wiring'
+const ARGS_TEXT = '@"meeting notes.md" and confirm the fixture wiring'
 const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
 
 async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
@@ -78,6 +78,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
       paceMs: 10,
     })
     await seedUserOnlySkill(scaffold.workspaceCwd)
+    await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting notes.md'), '# Meeting notes\n\nSent reference preview.\n')
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -159,6 +160,28 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
 
+  it('previews sent skill and quoted file references with prose-link hover styling after reloading history', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-sent-reference-preview'))
+    await page.reload({ waitUntil: 'load' })
+    const skill = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="skill"]').first()
+    await skill.waitFor({ timeout: 15_000 })
+    const preview = page.locator('[data-document-markdown]')
+    await skill.hover()
+    await expect.poll(() => skill.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
+    await skill.click()
+    await expect.poll(() => preview.textContent(), { timeout: 10_000 }).toContain('Reply with the fixture acknowledgement line.')
+    const file = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="file"]').first()
+    await file.hover()
+    expect(await file.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
+    await file.click()
+    await expect.poll(() => preview.textContent()).toContain('Sent reference preview.')
+    await skill.click()
+    await expect.poll(() => preview.textContent()).toContain('Reply with the fixture acknowledgement line.')
+    expect(await page.locator('[data-chat-flow-kind="user"]').first().textContent()).toContain('and confirm the fixture wiring')
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
   it('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
   })

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 79878fc8daaa5ba3e64d4ffb27d279ff172b6749
-config-catalog.zh.md: f254241618d280acaf738cc011ba1f7c5a2b6b21
+config-catalog.md: 7c975892a0a5f04a80dd13186ef2b3732b419400
+config-catalog.zh.md: 57dac94311814e87a2bf7385c404811a910d3d4b

+ 1 - 1
docs/config-catalog.md

@@ -2179,7 +2179,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts)
+Source: [`packages/skill/skill/src/index.ts:278`](../packages/skill/skill/src/index.ts)
 
 <a id="deepseek-aidsh-skill-filesystem"></a>
 

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

@@ -2181,7 +2181,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts)
+来源:[`packages/skill/skill/src/index.ts:278`](../packages/skill/skill/src/index.ts)
 
 <a id="deepseek-aidsh-skill-filesystem"></a>
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 323e9f3eea8703c46f3be082db6ab67e9339084b
-event-producer-consumer.zh.md: 1f34a6f3d53bfd6ddf6c19adb0a3c7cb7043c6ab
+event-producer-consumer.md: ffb0cc432e5af452ba7369867a718a582bb03c4c
+event-producer-consumer.zh.md: 5123454eaeaa63e2c52fcc5220254c5dea3a5d1f

+ 6 - 6
docs/event-producer-consumer.md

@@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
-| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 6 - 6
docs/event-producer-consumer.zh.md

@@ -24,11 +24,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -56,7 +56,7 @@
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
-| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 2 - 2
docs/subsystems/skills.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/skills.md
-skills.md: 8224b91290c110d52d739cd85b3fc7d641f9a7ea
-skills.zh.md: 018bd85d74ba40717c741d6600f991f3c6d36d4c
+skills.md: 84165578d37c0d947f20435d9605bb685a5b673b
+skills.zh.md: 02fd41d5bc3a93ea4aa6de2a9996bc91bd648859

+ 2 - 4
docs/subsystems/skills.md

@@ -106,6 +106,8 @@ interface SkillInvocationPolicy {
 ```ts type-equiv
 /** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
 interface SkillSummary {
+  /** Absolute instruction file path when supplied by the provider; absent for virtual skills. */
+  readonly path?: string
   /** Kebab-case identifier used to address the skill. */
   readonly name: string
   /** Short routing description shown by discovery consumers. */
@@ -146,8 +148,6 @@ interface SkillCandidate extends SkillSummary {
   readonly rank: number
   /** Opaque provider-owned handle passed back to `provider.get()`. */
   readonly locator: unknown
-  /** Absolute file path when the provider has one. */
-  readonly path?: string
   /** Parsed optional metadata object from provider-specific skill frontmatter. */
   readonly metadata?: Readonly<Record<string, unknown>>
 }
@@ -168,8 +168,6 @@ type SkillResourceBase =
 interface SkillDefinition extends SkillSummary {
   /** Markdown instruction body after any provider-specific metadata removal. */
   readonly content: string
-  /** Absolute file path when the skill came from disk. */
-  readonly path?: string
   /** Parsed optional metadata object from frontmatter. */
   readonly metadata?: Readonly<Record<string, unknown>>
 }

+ 2 - 4
docs/subsystems/skills.zh.md

@@ -106,6 +106,8 @@ interface SkillInvocationPolicy {
 ```ts type-equiv
 /** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
 interface SkillSummary {
+  /** Absolute instruction file path when supplied by the provider; absent for virtual skills. */
+  readonly path?: string
   /** Kebab-case identifier used to address the skill. */
   readonly name: string
   /** Short routing description shown by discovery consumers. */
@@ -146,8 +148,6 @@ interface SkillCandidate extends SkillSummary {
   readonly rank: number
   /** Opaque provider-owned handle passed back to `provider.get()`. */
   readonly locator: unknown
-  /** Absolute file path when the provider has one. */
-  readonly path?: string
   /** Parsed optional metadata object from provider-specific skill frontmatter. */
   readonly metadata?: Readonly<Record<string, unknown>>
 }
@@ -168,8 +168,6 @@ type SkillResourceBase =
 interface SkillDefinition extends SkillSummary {
   /** Markdown instruction body after any provider-specific metadata removal. */
   readonly content: string
-  /** Absolute file path when the skill came from disk. */
-  readonly path?: string
   /** Parsed optional metadata object from frontmatter. */
   readonly metadata?: Readonly<Record<string, unknown>>
 }

+ 0 - 1
package.json

@@ -185,7 +185,6 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-package-manifest": "workspace:^",
     "@deepseek-ai/dsh-tool-session-query": "workspace:^",
     "@deepseek-ai/dsh-web-fetch-http": "workspace:^",
     "@stylistic/eslint-plugin": "^5.10.0",

+ 2 - 2
packages/api/session-controller/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/api/session-controller/README.md
-README.md: 24cfbba626ddec510452e3b6c8bc4333c03876bd
-README.zh.md: 883fa2343693e6512638fa437a3f8bd0db09310c
+README.md: 768c8704775d422c9c48d7d40a58d589f715b718
+README.zh.md: 3c1de73d13678b15af233196894b51fffa952f67

+ 2 - 0
packages/api/session-controller/README.md

@@ -35,6 +35,8 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream`
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 
 
+The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
+
 <a id="session-media-references"></a>
 ## Session media references
 

+ 2 - 0
packages/api/session-controller/README.zh.md

@@ -35,6 +35,8 @@ Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
 
 
+面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
+
 <a id="session-media-references"></a>
 ## 会话媒体引用
 

+ 1 - 0
packages/api/session-controller/src/skill-catalog.ts

@@ -78,6 +78,7 @@ export class SessionSkillCatalog extends TypertRemoteService {
       return {
         skills: skills.map(skill => ({
           name: skill.name,
+          ...skill.path === undefined ? {} : { path: skill.path },
           description: skill.description,
           ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
           modelInvocable: skill.invocation.modelInvocable,

+ 2 - 0
packages/api/session-controller/src/types.ts

@@ -223,6 +223,8 @@ export interface SkillListRequest {
 
 /** One skill available to the Session's human-facing composer. */
 export interface SkillEntry {
+  /** Absolute SKILL.md path when supplied by a filesystem provider. */
+  readonly path?: string
   /** Kebab-case identifier referenced as `/name`. */
   readonly name: string
   /** Short routing description. */

+ 2 - 0
packages/api/session-controller/tests/session-skills.host.spec.ts

@@ -57,6 +57,7 @@ describe('SessionSkillCatalog', () => {
         name: 'review',
         description: 'Review the current change.',
         whenToUse: 'Before publishing.',
+        path: '/cold/project/.agents/skills/review/SKILL.md',
         invocation: { modelInvocable: true, userInvocable: true },
       },
       {
@@ -73,6 +74,7 @@ describe('SessionSkillCatalog', () => {
         name: 'review',
         description: 'Review the current change.',
         whenToUse: 'Before publishing.',
+        path: '/cold/project/.agents/skills/review/SKILL.md',
         modelInvocable: true,
       }],
     })

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: df386b64089f962960b09538381e9d4b4905feb0
-README.zh.md: 2698aa248e97db0aac53fd4a2e8bea1adaa3179a
+README.md: a525440f20879314bd8d12b647830f2c541f59b3
+README.zh.md: 90aa0beda52c2968c93eab13bae11640e81e0664

+ 1 - 1
packages/boot/app-boot/README.md

@@ -45,7 +45,7 @@ With that entry point, success looks like a running app with every plugin active
 <a id="profiles"></a>
 ### Profiles
 
-Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data.
+Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
 
 A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -45,7 +45,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
 <a id="profiles"></a>
 ### Profile
 
-Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
+Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
 
 profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。缺失组合包或未声明 patch 的组合包会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
 

+ 4 - 9
packages/boot/app-boot/src/profile.ts

@@ -34,7 +34,7 @@ import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import type { DshManifest, DshModuleFallbackManifest, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { DshPackageManifest, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports'
 import { loadOverlayPatches } from './index.ts'
 
@@ -55,13 +55,8 @@ export interface ProfileTemplate {
   patchReload: ProfilePatchReload
 }
 
-/** The slice of package.json both profiles and bundles use. */
-export interface ProfileManifest {
-  name?: string
-  dependencies?: Record<string, string>
-  peerDependencies?: Record<string, string>
-  dsh?: DshManifest
-}
+/** Package metadata accepted by the profile reader; local profiles need no published identity. */
+export type ProfileManifest = Partial<DshPackageManifest>
 
 /** One resolved bundle layer of a profile. */
 export interface ProfileLayer {
@@ -308,7 +303,7 @@ interface ModuleProxyManifest {
   private: true
   type: 'module'
   exports: Record<string, string>
-  dsh: { moduleFallback: DshModuleFallbackManifest }
+  dsh: { moduleFallback: { targets: Record<string, string> } }
 }
 
 interface ModuleProxyRecord {

+ 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: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8
-README.zh.md: c185cfd8618f765b735c505d54f4ecf4745d2183
+README.md: 2499163dcc0795278ce67bed38be2ed106bbae20
+README.zh.md: 33fa9e30fd0c9e46cd9c600decd89e4dc0357921

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

@@ -14,6 +14,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 
 ## Table of Contents
 
+- [Reference previews](#reference-previews)
 - [System prompt row](#system-prompt-row)
 - [Turn token usage](#turn-token-usage)
 - [Turn Process Folding](#turn-process-folding)
@@ -24,6 +25,11 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 
 -----
 
+<a id="reference-previews"></a>
+## Reference previews
+
+Sent file references and skills confirmed by the message’s logged invocation 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>
 ## System prompt row
 

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

@@ -14,6 +14,7 @@ kind: "package-reference"
 
 ## 目录
 
+- [引用预览](#reference-previews)
 - [系统提示词行](#system-prompt-row)
 - [轮次 token 用量](#turn-token-usage)
 - [轮次过程折叠](#turn-process-folding)
@@ -24,6 +25,11 @@ kind: "package-reference"
 
 -----
 
+<a id="reference-previews"></a>
+## 引用预览
+
+已发送的文件引用及消息日志确认调用的 skill 可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
+
 <a id="system-prompt-row"></a>
 ## 系统提示词行
 

+ 4 - 2
packages/client/ui-chat/package.json

@@ -32,11 +32,12 @@
         "@deepseek-ai/dsh-api-workspace-controller",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-input-trigger",
         "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-session",
-        "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-settings",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-workspace"
       ],
       "platform": "web"
@@ -62,13 +63,14 @@
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-approval": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",

+ 6 - 0
packages/client/ui-chat/src/client/apply.ts

@@ -6,6 +6,7 @@ import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/cli
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 // The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
 import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
@@ -136,6 +137,11 @@ export function apply(ctx: Context): void {
             else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
             await Promise.resolve()
           },
+          openSkill: (name) => {
+            const scope = ctx.sessions.scope(sessionId)
+            if (scope === undefined) return
+            ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` })
+          },
           loadOlder: () => { void session.loadOlder() },
           loadThrough: seq => session.loadThrough(seq),
           loadImage: Object.assign(

+ 3 - 2
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -37,7 +37,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
 /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
   nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
-  cwd, openFile, inspectCall, forkAt,
+  cwd, openFile, openSkill, inspectCall, forkAt,
   loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
   const node = useChatNode(nodeKey)
@@ -105,6 +105,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
     : {
       cwd,
       openFile,
+      openSkill,
       inspectCall,
       forkAt,
       loadImage,
@@ -112,7 +113,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
       fileMentions,
       turnProcess,
     }, [
-    node, cwd, openFile, inspectCall, forkAt,
+    node, cwd, openFile, openSkill, inspectCall, forkAt,
     loadImage, renderMessageImages, fileMentions, turnProcess,
   ])
   if (routedNode === undefined || owner === null) return null

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

@@ -216,7 +216,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
  */
 export function ChatView({
   useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
-  sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
+  sessionId, openFile, openSkill, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
   useTranscriptView, useProjection, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
@@ -791,6 +791,7 @@ export function ChatView({
             actions={actions}
             cwd={cwd}
             openFile={requestOpenFile}
+            openSkill={openSkill}
             inspectCall={inspectCall}
             forkAt={forkAt}
             loadImage={loadImage}

+ 5 - 3
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -156,7 +156,7 @@ function TurnMaxTokensItem({ t }: {
 /** Right-aligned bubble shared by user and steering rows. */
 function UserStyleBubble({
   content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [],
-  previewAttachments, t,
+  previewAttachments, references, t,
 }: {
   content: readonly unknown[]
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -172,6 +172,7 @@ function UserStyleBubble({
   skillNames?: readonly string[]
   /** Local submission-echo attachments replacing the content-derived attachment sequence. */
   previewAttachments?: readonly PresentedAttachment[]
+  references?: Pick<ChatNodeOwnerProps, 'openFile' | 'openSkill'>
   t: ChatViewSlotProps['t']
 }): ReactNode {
   const { text, attachments: contentAttachments, rest } = contentParts(content)
@@ -213,7 +214,7 @@ function UserStyleBubble({
           </div>
         )}
         {showBubble && <div className={css.bubble}>
-          {projectUserText(text, referenceLabels, skillNames)}
+          {projectUserText(text, referenceLabels, skillNames, 'skill', references)}
           {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
         </div>}
         {referenceLabels.length > 0 && (
@@ -312,12 +313,13 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
 
 /** User and admitted-steering keyed Chat renderer. */
 export const UserMessageNodeView = memo(function UserMessageNodeView({
-  node, renderMessageImages, t,
+  node, renderMessageImages, openFile, openSkill, t,
 }: ChatNodeViewProps<'user' | 'steering'>) {
   const data = node.data
   return (
     <UserStyleBubble
       content={data.content}
+      references={{ openFile, openSkill }}
       renderMessageImages={renderMessageImages}
       {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
       {...data.skillNames === undefined ? {} : { skillNames: data.skillNames }}

+ 4 - 0
packages/client/ui-chat/src/client/contract/slots.ts

@@ -79,6 +79,8 @@ export interface ChatNodeTurnDataInjected {
 /** Stable owner currency delivered to a keyed Chat renderer. */
 export interface ChatNodeOwnerProps {
   cwd?: string | undefined
+  /** Open the current source file of a skill referenced by a sent message. */
+  openSkill: (name: string) => void
   openFile: (path: string, options?: OpenFileOptions) => void
   inspectCall: (callId: ToolCallId) => void
   forkAt: (seq: number) => void
@@ -138,6 +140,8 @@ export interface ChatViewInjected {
     /** Resolve the stable Turn-process source for one Chat Node key. */
     chatNodeProcess: (key: string) => ChatNodeProcessSource
   }
+  /** Open the current source file of a skill referenced by a sent message. */
+  openSkill: (name: string) => void
   openFile: (path: string, options?: OpenFileOptions) => Promise<void>
   loadOlder: () => void
   /** Jump loader: page history back through seq; resolves when the window covers it. */

+ 16 - 0
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -141,6 +141,22 @@ describe('Chat inject API', () => {
     await b.runtime.dispose()
   })
 
+  it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => {
+    const b = await bench()
+    const { injected } = b.chatViewApi(ROOT)
+    injected.openSkill('review')
+    const openReference = vi.fn(() => true)
+    const sessionOf = vi.fn(() => ({ openReference }))
+    b.runtime.ctx.provide('inputTriggers', { sessionOf } as never)
+    injected.openSkill('review')
+    expect(sessionOf).toHaveBeenCalledWith(b.runtime.sessions.scope(ROOT))
+    expect(openReference).toHaveBeenCalledWith('skill', { ref: '/review' })
+    vi.spyOn(b.runtime.sessions, 'scope').mockReturnValueOnce(undefined)
+    injected.openSkill('review')
+    expect(openReference).toHaveBeenCalledTimes(1)
+    await b.runtime.dispose()
+  })
+
   it('keeps a relative path under the Session without a cwd, and addresses a path outside the workspace absolutely', async () => {
     const b = await bench()
     const NO_CWD = 'root-2' as SessionId

+ 3 - 1
packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx

@@ -64,7 +64,9 @@ function MessageItem({ node, t: translate, referenceLabels, skillNames }: Messag
         }
         : node,
   }
-  const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps
+  const props = {
+    node: viewNode, t: translate, renderMessageImages, openFile: vi.fn(), openSkill: vi.fn(), useChat: useDetachedChat,
+  } as unknown as ChatNodeViewProps
   switch (node.kind) {
     case 'user':
     case 'steering':

+ 3 - 1
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -250,6 +250,7 @@ function makeHarness(
     key => chatSource.source.getSnapshot().nodes.processSource(key),
   )
   const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
+  const openSkill = vi.fn<(name: string) => void>()
   const loadOlder = vi.fn()
   const loadThrough = vi.fn<(seq: number) => Promise<void>>().mockResolvedValue(undefined)
   // Mutable outline holder: tests swap the value and drive a re-render via set().
@@ -396,6 +397,7 @@ function makeHarness(
     openView,
     completeViewRequest: () => {},
     openFile,
+    openSkill,
     loadOlder,
     loadThrough,
     loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
@@ -425,7 +427,7 @@ function makeHarness(
   }
   return {
     set, setSession: session.set, setChat: chatSource.set, ChatView, props,
-    openFile, loadOlder, loadThrough, openView,
+    openFile, openSkill, loadOlder, loadThrough, openView,
     setOutline: (value: unknown) => { outlineValue = value },
     chatScroll, forkAt, toolOwners,
     setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },

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

@@ -100,6 +100,9 @@
     },
     {
       "path": "../ui-sidebar-documentpreview"
+    },
+    {
+      "path": "../ui-input-trigger"
     }
   ]
 }

+ 2 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: 7d5f370644f90f57ce1c27c59becf47c4cf2e684
-README.zh.md: f66ad7efa9d077f58cabe4d5d53956a544e64f8c
+README.md: 6471419852b7fdd09539f5dbd669f1d24f06460e
+README.zh.md: c6fabbc1bb85ee5c6f2c6ca559feda59ef88aac2

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

@@ -52,6 +52,8 @@ Queued submission echoes show “Sending…” beside disabled edit, remove, and
 
 Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
 
+File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission.
+
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries
 

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

@@ -52,6 +52,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
 
+文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
+
 <a id="temporary-composer-entries"></a>
 ## 临时 composer entry
 

+ 6 - 0
packages/client/ui-conversation/src/client/contract/input.ts

@@ -137,6 +137,12 @@ export interface InputTriggerController {
     signal: AbortSignal,
     envelope: { readonly attachments: number },
   ): Promise<PickOutcome>
+  /**
+   * @param source - chip owner, or undefined for a text reference.
+   * @param reference - source id and glyph.
+   * @returns whether a preview opened.
+   */
+  openReference(source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>): boolean
   /** @param source - source name. @param hit - synthetic trigger hit. */
   toggleSource(source: string, hit: InputTriggerHit): void
 }

+ 3 - 12
packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css

@@ -1,22 +1,12 @@
-/* Inline reference chip: a real DOM capsule (the three-layer backdrop trick
-   and its advance-preserving constraints are gone — background, padding,
-   radius, and label truncation are ordinary styles here). Vertical metrics
-   stay inside the composer's 24px line so a chip never changes line height. */
+/* Atomic reference metrics stay inside the composer line. */
 
 .chip {
   display: inline-flex;
-  align-items: center;
+  align-items: baseline;
   gap: 3px;
   max-width: 240px;
-  padding: 0 6px;
-  border-radius: 6px;
-  vertical-align: bottom;
-  line-height: 22px;
-  height: 22px;
-  background: var(--dsw-alias-interactive-bg-hover);
   color: var(--dsw-alias-state-business-primary);
   user-select: none;
-  cursor: default;
 }
 
 .marker {
@@ -26,6 +16,7 @@
 
 .icon {
   flex: none;
+  align-self: center;
 }
 
 .label {

+ 2 - 1
packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx

@@ -8,6 +8,7 @@ import type { ReactNode } from 'react'
 import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ReferenceIconKind } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './ReferenceChip.module.css'
+import referenceCss from './composer-editor.module.css'
 
 /** Display inputs of one chip (the node's cached owner projections). */
 export interface ReferenceChipProps {
@@ -25,7 +26,7 @@ export interface ReferenceChipProps {
  */
 export function ReferenceChip({ label, appearance, invalid }: ReferenceChipProps): ReactNode {
   return (
-    <span className={clsx(css.chip, invalid && css.invalid)} title={label}>
+    <span className={clsx(referenceCss.reference, css.chip, appearance === 'file' && !invalid && referenceCss.openable, invalid && css.invalid)} title={label}>
       {appearance === undefined
         ? <span className={css.marker} aria-hidden>@</span>
         : <ReferenceIcon kind={appearance} size={14} className={css.icon} />}

+ 20 - 9
packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css

@@ -1,13 +1,24 @@
-/* Editor-internal decoration styles: nodes Lexical mounts inside the
-   contenteditable (chip hosts get their look from ReferenceChip.module.css;
-   this sheet covers text-level decorations). */
-
-/* Plain-text reference: chip family colors over the draft's own glyphs.
-   clone keeps rounded ends on soft-wrap fragments. Color only, no icon —
-   a token still carrying its trigger character is editable text; the domain
-   icon belongs to the settled chip alone. */
-.textRef {
+/* Shared inline reference treatment; hover does not change text metrics. */
+.reference {
+  padding: 0 4px;
+  border-radius: 6px;
+  line-height: inherit;
+  vertical-align: baseline;
   color: var(--dsw-alias-state-business-primary);
+  background: transparent;
   box-decoration-break: clone;
   -webkit-box-decoration-break: clone;
 }
+
+.reference:hover {
+  background: var(--dsw-alias-state-business-tertiary);
+}
+
+.openable {
+  cursor: pointer;
+}
+
+.textRef {
+  display: inline-block;
+  max-width: 100%;
+}

+ 33 - 0
packages/client/ui-conversation/src/client/input/editor/reference-activation.ts

@@ -0,0 +1,33 @@
+/** Route composer clicks through the live reference owner without editing the draft. */
+import {
+  $getNearestNodeFromDOMNode, $getSelection, $isRangeSelection,
+  CLICK_COMMAND, COMMAND_PRIORITY_LOW,
+} from 'lexical'
+import type { LexicalEditor } from 'lexical'
+import type { ReferenceInsert } from '../../contract/input.ts'
+import { $isReferenceChipNode } from './chip-node.tsx'
+import { TextRefNode } from './text-ref.ts'
+
+/**
+ * Install preview activation for atomic chips and editable reference tokens.
+ * @param editor - composer editor.
+ * @param open - live source routing; false preserves ordinary editor handling.
+ * @returns command disposer.
+ */
+export function registerReferenceActivation(
+  editor: LexicalEditor,
+  open: (source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>) => boolean,
+): () => void {
+  return editor.registerCommand(CLICK_COMMAND, (event) => {
+    if (event.target === null || event.button !== 0 || event.detail > 1) return false
+    const selection = $getSelection()
+    if ($isRangeSelection(selection) && !selection.isCollapsed()) return false
+    const node = $getNearestNodeFromDOMNode(event.target as Node)
+    if ($isReferenceChipNode(node)) {
+      if (node.isInvalid()) return false
+      const appearance = node.getAppearance()
+      return open(node.getSource(), { ref: node.getReference(), ...appearance === undefined ? {} : { appearance } })
+    }
+    return node instanceof TextRefNode && open(undefined, { ref: node.getTextContent() })
+  }, COMMAND_PRIORITY_LOW)
+}

+ 4 - 12
packages/client/ui-conversation/src/client/input/editor/text-ref.ts

@@ -1,14 +1,5 @@
-/**
- * Plain-text reference decoration (the plain-text-reference decision;
- * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
- * a `/name` or `@name` token whose name is on the trigger's lexicon, and
- * syntax-recognizable `@dir/` folder tokens, render in the chip family
- * colors. Color only, no icon: a token still carrying its trigger character
- * is editable text, not a settled chip — the domain icon marks exactly the
- * settled state. Pure derivation as before — the entity transform converts
- * matching text into TextRefNode and back as edits move it in and out of
- * match shape; no occurrence identity exists.
- */
+/** Editable reference tokens share chip hover styling while retaining ordinary text semantics. */
+import clsx from 'clsx'
 import type { EditorConfig, LexicalEditor, SerializedTextNode } from 'lexical'
 import { TextNode } from 'lexical'
 import { registerLexicalTextEntity } from '@lexical/text'
@@ -61,7 +52,8 @@ export class TextRefNode extends TextNode {
   /** Style the span the base TextNode mounts. */
   override createDOM(config: EditorConfig): HTMLElement {
     const el = super.createDOM(config)
-    el.classList.add(css.textRef ?? 'textRef')
+    el.className = clsx(el.className, css.reference, css.textRef, this.getTextContent().startsWith('/') && css.openable)
+    el.setAttribute('spellcheck', 'false')
     el.setAttribute('data-composer-text-ref', '')
     return el
   }

+ 3 - 0
packages/client/ui-conversation/src/client/input/facade.ts

@@ -28,6 +28,7 @@ import type {
 } from '../contract/input.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import { SubmitMachine } from './machine.ts'
+import { registerReferenceActivation } from './editor/reference-activation.ts'
 import { ReferenceChipNode, $createReferenceChipNode } from './editor/chip-node.tsx'
 import { refreshClaimDecoration, registerClaimDecoration } from './editor/claim-decor.ts'
 import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './editor/text-ref.ts'
@@ -178,6 +179,8 @@ export class SessionInputShell implements SessionInput {
     })
     this.unregister = mergeRegister(
       registerPlainText(this.editor),
+      registerReferenceActivation(this.editor, (source, reference) =>
+        this.deps.inputTriggers?.()?.openReference(source, reference) ?? false),
       registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
       this.editor.registerUpdateListener(() => { this.onEditorUpdate() }),
       registerClaimDecoration(this.editor, () => this.activeClaimToken()),

+ 64 - 0
packages/client/ui-conversation/tests/reference-activation.client.spec.ts

@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+/** Composer clicks use the owning Lexical node while selection gestures remain editable. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { $createParagraphNode, $createTextNode, $getRoot, createEditor } from 'lexical'
+import { registerPlainText } from '@lexical/plain-text'
+import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx'
+import { TextRefNode } from '../src/client/input/editor/text-ref.ts'
+import { registerReferenceActivation } from '../src/client/input/editor/reference-activation.ts'
+
+const cleanups: Array<() => void> = []
+afterEach(() => { for (const cleanup of cleanups.splice(0).reverse()) cleanup() })
+
+function bench() {
+  const editor = createEditor({ nodes: [ReferenceChipNode, TextRefNode], onError: (error) => { throw error } })
+  const root = document.createElement('div')
+  root.contentEditable = 'true'
+  document.body.append(root)
+  editor.setRootElement(root)
+  cleanups.push(() => { editor.setRootElement(null); root.remove() })
+  cleanups.push(registerPlainText(editor))
+  const open = vi.fn(() => true)
+  const off = registerReferenceActivation(editor, open)
+  cleanups.push(off)
+  let chip!: ReferenceChipNode
+  let text!: TextRefNode
+  editor.update(() => {
+    chip = new ReferenceChipNode({ source: 'reference', ref: '@a.md', label: 'a.md', appearance: 'file', clipboardText: '@a.md' })
+    text = new TextRefNode('/review')
+    $getRoot().append($createParagraphNode().append(chip, $createTextNode(' '), text))
+  }, { discrete: true })
+  const click = (key: string, detail = 1) => {
+    const target = editor.getElementByKey(key)!
+    target.dispatchEvent(new MouseEvent('click', { bubbles: true, detail }))
+  }
+  return { editor, root, chip, text, open, click, off }
+}
+
+describe('reference activation', () => {
+  it('opens chip and editable skill references without changing draft content', () => {
+    const { editor, chip, text, open, click, off } = bench()
+    click(chip.getKey())
+    expect(open).toHaveBeenLastCalledWith('reference', { ref: '@a.md', appearance: 'file' })
+    click(text.getKey())
+    expect(open).toHaveBeenLastCalledWith(undefined, { ref: '/review' })
+    expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe('@a.md /review')
+    off()
+    click(text.getKey())
+    expect(open).toHaveBeenCalledTimes(2)
+  })
+
+  it('opens once in a double-click sequence and leaves selections, invalid chips, and ordinary text to the editor', () => {
+    const { editor, root, chip, text, open, click } = bench()
+    click(text.getKey(), 1)
+    click(text.getKey(), 2)
+    expect(open).toHaveBeenCalledTimes(1)
+    open.mockClear()
+    root.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    editor.update(() => { text.select(0, 3) }, { discrete: true })
+    click(text.getKey())
+    editor.update(() => { text.select(0, 0); chip.setInvalid(true) }, { discrete: true })
+    click(chip.getKey())
+    expect(open).not.toHaveBeenCalled()
+  })
+})

+ 2 - 2
packages/client/ui-input-trigger/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-input-trigger/README.md
-README.md: 883be41f07e05a4c27a0b31d186c41fa5fa96fb9
-README.zh.md: 75fbd7887e7cad4286a4eeae6ce72295b80b4d2b
+README.md: aa8919ed9aa0b883fef2bbd33e9bce36ffb0e535
+README.zh.md: cb552d9ade693bdd4a3a7e93d4ed87f0e217f8e6

+ 2 - 0
packages/client/ui-input-trigger/README.md

@@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
 
 The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight, Tab passes untouched so native focus traversal survives. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
 
+A source may implement `openReference(session, reference)` to open a draft reference without submitting it. Acceptance may precede asynchronous catalog loading. Chips route by source name; editable tokens route through the current source lexicon. Returning `false`, a missing source, or a disposed controller leaves the editor gesture unchanged.
+
 -----
 
 <a id="understand-the-implementation"></a>

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

@@ -31,6 +31,8 @@ kind: "package-reference"
 
 菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:流水线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
 
+来源可以实现 `openReference(session, reference)`,打开草稿引用而不提交。来源可以先接受预览请求,再异步加载目录。标签按来源名称路由;可编辑文本按来源当前的词表路由。返回 `false`、来源缺失或控制器已释放时,保留编辑器原有的手势处理。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 22 - 1
packages/client/ui-input-trigger/src/client/controller.ts

@@ -10,7 +10,7 @@
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type {
-  ArbitrateKey, ArbitrateOutcome, PickOutcome,
+  ArbitrateKey, ArbitrateOutcome, PickOutcome, ReferenceInsert,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { detectTrigger } from '../core/detect.ts'
@@ -305,6 +305,27 @@ export class InputTriggerController {
     return owner.codec.serialize(ref, signal)
   }
 
+  /**
+   * Route a chip to its owner or an editable token to its current lexicon owner.
+   * @param source - chip source name; undefined for editable text.
+   * @param reference - source-owned id and optional chip glyph.
+   * @returns whether an owner accepted the preview, possibly awaiting its catalog.
+   */
+  openReference(source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>): boolean {
+    if (this.disposed) return false
+    const session = this.project()
+    for (const owner of this.deps.roster.all()) {
+      const matches = source === undefined
+        ? reference.ref.startsWith(owner.trigger) && owner.lexicon?.(session)?.includes(reference.ref.slice(1))
+        : owner.name === source
+      if (matches && owner.openReference?.(session, reference)) {
+        this.dismiss()
+        return true
+      }
+    }
+    return false
+  }
+
   /**
    * Enter last adjudication: polls sources' matchEnter in registration
    * order, first non-undefined wins. The outcome returns to the caller (the

+ 8 - 1
packages/client/ui-input-trigger/src/types.ts

@@ -8,7 +8,7 @@
  * through the provider plugin's own root context captured at registration.
  */
 import type {
-  PickOutcome, TokenSpan,
+  PickOutcome, ReferenceInsert, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
@@ -211,6 +211,13 @@ export interface InputTriggerSource {
    * @returns unsubscribe.
    */
   subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
+  /**
+   * Open a reference preview without changing or submitting the draft.
+   * @param session - session owning the composer.
+   * @param reference - source-owned id and optional chip glyph; text references retain their trigger.
+   * @returns whether this source accepted the preview, possibly awaiting its catalog; false leaves the editor gesture unchanged.
+   */
+  openReference?(session: ClientSessionContext, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>): boolean
   /** Reference codec; required for sources producing insert outcomes. */
   readonly codec?: ReferenceCodec
 }

+ 23 - 0
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -1163,3 +1163,26 @@ describe('adjudicate', () => {
     expect(hook).not.toHaveBeenCalled()
   })
 })
+
+describe('reference activation', () => {
+  it('routes chips by owner and text by the live lexicon without picking or serializing', () => {
+    const openReference = vi.fn(() => true)
+    const lexicon = vi.fn(() => ['review'])
+    const skill = deferredSource('/', 'skill', { lexicon, openReference }).source
+    const inert = deferredSource('/', 'inert', { lexicon }).source
+    const { controller, sources } = controllerBench([inert, skill])
+    expect(controller.openReference(undefined, { ref: '/unknown' })).toBe(false)
+    expect(controller.openReference('missing', { ref: '/review' })).toBe(false)
+    expect(controller.openReference(undefined, { ref: '/review' })).toBe(true)
+    expect(openReference).toHaveBeenCalledWith({ sessionId: sid('a') }, { ref: '/review' })
+    expect(controller.openReference('skill', { ref: 'opaque', appearance: 'file' })).toBe(true)
+    lexicon.mockReturnValue([])
+    expect(controller.openReference(undefined, { ref: '/review' })).toBe(false)
+    openReference.mockReturnValue(false)
+    expect(controller.openReference('skill', { ref: 'opaque' })).toBe(false)
+    sources.splice(0)
+    expect(controller.openReference('skill', { ref: '/review' })).toBe(false)
+    controller.dispose()
+    expect(controller.openReference('skill', { ref: '/review' })).toBe(false)
+  })
+})

+ 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: c4b154d48f54c691739cd429903d42791e85a132
-README.zh.md: 40ac6cacafc8c11a32802d39122c83994cd0a69a
+README.md: c7536d946516e9626d1880244e879663d7565cd2
+README.zh.md: 0bba56e593662aed05394e918f570c83ad2d7ca3

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -71,7 +71,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. 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.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. 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.
 
 
 ### Localizing copy

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -71,7 +71,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 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,供消息气泡和排队行使用。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 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 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 
 ### 本地化文案

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

@@ -37,7 +37,7 @@ export { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.ts
 export type {
   CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext,
 } from './FileTypeIcon.tsx'
-export { projectUserText } from './user-text.tsx'
+export { projectUserText, type UserTextReferences } from './user-text.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
 export { Toast } from './Toast.tsx'

+ 38 - 10
packages/client/ui-primitives/src/user-text.tsx

@@ -18,6 +18,7 @@ import type { ReactNode } from 'react'
 import clsx from 'clsx'
 import { ReferenceIcon } from './ReferenceIcon.tsx'
 import css from './user-text.module.css'
+import markdownCss from './markdown/MarkdownText.module.css'
 
 /** The wire form a session chip serializes to; label is the display text. */
 const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
@@ -35,6 +36,14 @@ interface DecorationRange {
   readonly display?: string
 }
 
+/** Optional navigation supplied by consumers that can preview references. */
+export interface UserTextReferences {
+  /** Open a file path decoded from an `@` mention. */
+  openFile: (path: string) => void
+  /** Open the source of a skill loaded for this message. */
+  openSkill: (name: string) => void
+}
+
 /**
  * Split one sent text into inline plain runs and reference chips.
  * @param text - the logged model text of the message or queue row.
@@ -43,6 +52,7 @@ interface DecorationRange {
  * host loaded for this message, or the command a command bubble echoes
  * (unsent queue rows pass none).
  * @param slashKind - the chip kind those tokens render as.
+ * @param references - optional file and skill preview actions; session and command tokens stay labels.
  * @returns inline nodes covering the whole text.
  */
 export function projectUserText(
@@ -50,6 +60,7 @@ export function projectUserText(
   sessionLabels: readonly string[],
   slashNames: readonly string[] = [],
   slashKind: 'skill' | 'command' = 'skill',
+  references?: UserTextReferences,
 ): ReactNode {
   const ranges: DecorationRange[] = []
   SESSION_WIRE_RE.lastIndex = 0
@@ -99,7 +110,7 @@ export function projectUserText(
     const referenceKind = kind === 'session'
       ? 'session'
       : label.startsWith('@')
-        ? label.endsWith('/') ? 'folder' : 'file'
+        ? label.replace(/^@"|"$/gu, '').endsWith('/') ? 'folder' : 'file'
         : undefined
     const displayLabel = range.display
       ?? (referenceKind === undefined
@@ -107,19 +118,36 @@ export function projectUserText(
         : referenceKind === 'session'
           ? label.slice(1)
           : label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1))
-    parts.push(
-      <span
+    const contents = <>
+      {referenceKind !== undefined && (
+        <ReferenceIcon kind={referenceKind} size={16} className={css.refIcon} />
+      )}
+      {displayLabel}
+    </>
+    const open = references === undefined ? undefined
+      : referenceKind === 'file'
+        ? () => { references.openFile(label.slice(1).replace(/^"|"$/gu, '')) }
+        : referenceKind === undefined && slashKind === 'skill'
+          ? () => { references.openSkill(label.slice(1)) }
+          : undefined
+    const className = clsx(css.refChip, referenceKind === undefined && css.slashChip)
+    parts.push(open === undefined
+      ? <span key={tokenStart} className={className} data-ref-chip={referenceKind ?? slashKind} title={label}>
+        {contents}
+      </span>
+      : <button
         key={tokenStart}
-        className={clsx(css.refChip, referenceKind === undefined && css.slashChip)}
+        type="button"
+        className={clsx(className, markdownCss.fileMention)}
         data-ref-chip={referenceKind ?? slashKind}
         title={label}
+        onClick={(event) => {
+          if (event.detail > 1 || (event.detail !== 0 && event.currentTarget.ownerDocument.getSelection()?.isCollapsed === false)) return
+          open()
+        }}
       >
-        {referenceKind !== undefined && (
-          <ReferenceIcon kind={referenceKind} size={16} className={css.refIcon} />
-        )}
-        {displayLabel}
-      </span>,
-    )
+        {contents}
+      </button>)
     cursor = end
   }
   if (parts.length === 0) return <span className={css.plainRun}>{text}</span>

+ 38 - 3
packages/client/ui-primitives/tests/user-text.client.spec.tsx

@@ -4,8 +4,8 @@
  * message (bubble regression), and wire session forms fold to their label
  * (queue-row readability).
  */
-import { describe, expect, it } from 'vitest'
-import { render } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render } from '@testing-library/react'
 import { projectUserText } from '../src/user-text.tsx'
 
 const project = (
@@ -102,10 +102,45 @@ describe('projectUserText', () => {
 
   it('falls back to the raw quoted label when the path has no basename', () => {
     const host = project('看 @"/" 下面')
-    const chip = host.querySelector('[data-ref-chip="file"]')!
+    const chip = host.querySelector('[data-ref-chip="folder"]')!
     expect(chip.textContent).toBe('"/"')
   })
 
+  it('opens decoded files and loaded skills without activating session, folder, or command references', () => {
+    const openFile = vi.fn()
+    const openSkill = vi.fn()
+    const view = render(<div>{projectUserText(
+      '@src/a.ts @"notes a.md" /review @history @dir/ @"dir a/"', ['history'], ['review'], 'skill',
+      { openFile, openSkill },
+    )}</div>)
+    fireEvent.click(view.getByRole('button', { name: 'a.ts' }))
+    fireEvent.click(view.getByRole('button', { name: 'notes a.md' }))
+    fireEvent.click(view.getByRole('button', { name: '/review' }))
+    expect(openFile.mock.calls).toEqual([['src/a.ts'], ['notes a.md']])
+    expect(openSkill).toHaveBeenCalledWith('review')
+    expect(view.container.querySelectorAll('button')).toHaveLength(3)
+    const command = render(<div>{projectUserText('/help', [], ['help'], 'command', { openFile, openSkill })}</div>)
+    expect(command.container.querySelector('button')).toBeNull()
+  })
+
+  it('preserves text-selection gestures and keyboard activation', () => {
+    const openFile = vi.fn()
+    const view = render(<div>{projectUserText('@notes.md', [], [], 'skill', { openFile, openSkill: vi.fn() })}</div>)
+    const button = view.getByRole('button', { name: 'notes.md' })
+    const selection = document.getSelection()!
+    const range = document.createRange()
+    range.selectNodeContents(button)
+    selection.addRange(range)
+    fireEvent.click(button, { detail: 1 })
+    expect(openFile).not.toHaveBeenCalled()
+    fireEvent.click(button, { detail: 0 })
+    expect(openFile).toHaveBeenCalledWith('notes.md')
+    selection.removeAllRanges()
+    openFile.mockClear()
+    fireEvent.click(button, { detail: 2 })
+    expect(openFile).not.toHaveBeenCalled()
+  })
+
   it('renders undecorated text as one inline run', () => {
     const host = project('纯文本,无引用')
     expect(host.querySelectorAll('div').length).toBe(0)

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md
-README.md: 7d09db53e2ef1fec35837b60f2822559e513d3e7
-README.zh.md: 3f4623bc594baee7e895a711359c974b51a410fc
+README.md: 1a79a22425fff1c7323a3bcb55afd324decbe5e8
+README.zh.md: 7cde5e46e69f008e9fcfffeb3920184d90bddb60

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

@@ -37,6 +37,8 @@ A session pick inserts an atomic inline reference whose hidden `ref` and clipboa
 
 One unavailable or failed candidate domain yields no rows for that domain while the other still lists. A session-reference preparation failure occurs after prompt acceptance and terminates that agent turn.
 
+Click a file reference in the composer to preview its current contents in the right Sidebar. Quoted paths retain their spaces, and paths resolve in the composer Session. Folder and Session references retain their editing behavior.
+
 -----
 
 <a id="understand-the-implementation"></a>

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

@@ -37,6 +37,8 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话
 
 某个候选领域不可用或失败时,该领域不产生任何行,另一领域仍正常列出。会话引用准备失败发生在提示词接受后,并会终止该 agent 轮次。
 
+点击输入框中的文件引用,可在右侧栏预览文件当前的内容。带引号路径中的空格会保留,路径按输入框所属 Session 解析。文件夹和 Session 引用保留原有的编辑行为。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 4 - 2
packages/client/ui-reference/package.json

@@ -32,7 +32,8 @@
         "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-ui-input-trigger"
+        "@deepseek-ai/dsh-client-ui-input-trigger",
+        "@deepseek-ai/dsh-client-ui-sidebar-right"
       ],
       "platform": "web"
     }
@@ -58,7 +59,8 @@
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 10 - 2
packages/client/ui-reference/src/client/index.ts

@@ -15,6 +15,7 @@
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
 import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -24,13 +25,13 @@ import type {
 import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar'
 import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
 import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types'
-import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
+import { abbreviateHomePath, fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
 import { en, NS, zh, type ReferenceKey } from './locales.ts'
 
 /** Required services: the trigger registry, the Remote namespaces, and the copy. */
 export const inject = [
   'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences',
-  'remote.sessionReferenceResolver',
+  'remote.sessionReferenceResolver', 'sidebarRight',
 ]
 
 /**
@@ -107,6 +108,13 @@ export function apply(ctx: ClientContext): void {
       }
       return undefined
     },
+    openReference(session, { ref, appearance }) {
+      if (appearance !== 'file') return false
+      const path = ref.startsWith('@"') ? ref.slice(2, -1) : ref.slice(1)
+      const cwd = sessions.list.getSnapshot().byId[session.sessionId]?.cwd
+      ctx.sidebarRight.openResource(fileAddressFor(session.sessionId, cwd, path))
+      return true
+    },
     codec: {
       clipboardText: ref => ref,
       serialize: ref => Promise.resolve(ref),

+ 18 - 1
packages/client/ui-reference/tests/browser-plugin.client.spec.ts

@@ -77,6 +77,7 @@ async function bench(
   listed: Record<string, { updatedAt: number }> = {},
 ): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']>; source: InputTriggerSource }> {
   const ctx = new Context()
+  ctx.provide('sidebarRight', { openResource: vi.fn() })
   let source: InputTriggerSource | undefined
   ctx.provide('inputTriggers', {
     registerSource(candidate: InputTriggerSource) {
@@ -106,11 +107,12 @@ describe('apply', () => {
   it('declares its services and releases the @ reference registration on disposal', async () => {
     expect(inject).toEqual([
       'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences',
-      'remote.sessionReferenceResolver',
+      'remote.sessionReferenceResolver', 'sidebarRight',
     ])
     const { fiber } = await bench()
     let registered: InputTriggerSource | undefined
     const ctx = new Context()
+    ctx.provide('sidebarRight', { openResource: vi.fn() })
     ctx.provide('inputTriggers', {
       registerSource(source: InputTriggerSource) {
         registered = source
@@ -503,3 +505,18 @@ describe('pick and codec', () => {
     expect(pick(source, { name: 'foreign candidate' })).toBeUndefined()
   })
 })
+
+describe('reference preview', () => {
+  it('opens plain and quoted file references without treating folders or sessions as files', async () => {
+    const { ctx, source, fiber } = await bench()
+    const openResource = vi.spyOn(ctx.sidebarRight, 'openResource')
+    expect(source.openReference?.(session, { ref: '@notes/readme.md', appearance: 'file' })).toBe(true)
+    expect(source.openReference?.(session, { ref: '@"docs/a b.md"', appearance: 'file' })).toBe(true)
+    expect(openResource).toHaveBeenNthCalledWith(1, 'dsh-resource://file/session/target/notes/readme.md')
+    expect(openResource).toHaveBeenNthCalledWith(2, 'dsh-resource://file/session/target/docs/a%20b.md')
+    expect(source.openReference?.(session, { ref: '@docs/', appearance: 'folder' })).toBe(false)
+    expect(source.openReference?.(session, { ref: '@[Research](dsh-session:abc)', appearance: 'session' })).toBe(false)
+    expect(openResource).toHaveBeenCalledTimes(2)
+    await fiber.dispose()
+  })
+})

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

@@ -43,6 +43,9 @@
     },
     {
       "path": "../ui-slots"
+    },
+    {
+      "path": "../ui-sidebar-right"
     }
   ]
 }

+ 2 - 2
packages/client/ui-skill/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-skill/README.md
-README.md: 65ee9bd20ddd3a1f2ec51e01193a215ffb27c62a
-README.zh.md: d59bae75620014539ebb75700899c85ba0171819
+README.md: a1e9e7047f102f2610160905727450833b540082
+README.zh.md: d7669b11fc7249c857e20f3c52f93b8bfd214281

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

@@ -35,6 +35,8 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves
 
 A collapsed row renders the skill glyph, `Skill` title, and requested skill name; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change.
 
+Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview.
+
 -----
 
 <a id="understand-the-implementation"></a>

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

@@ -35,6 +35,8 @@ kind: "package-reference"
 
 收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断的调用使用警告状态。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
 
+鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。
+
 -----
 
 <a id="understand-the-implementation"></a>

Some files were not shown because too many files changed in this diff