فهرست منبع

Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-3828

_Kerman 2 روز پیش
والد
کامیت
c09574fc5f
100فایلهای تغییر یافته به همراه839 افزوده شده و 208 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  2. 5 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  3. 5 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml
  5. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
  6. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
  8. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
  9. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml
  11. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
  12. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml
  14. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md
  15. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md
  16. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
  17. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
  18. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
  19. 2 2
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
  20. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
  21. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
  22. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
  23. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
  24. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
  25. 2 2
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml
  26. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md
  27. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md
  28. 6 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
  29. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
  30. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
  31. 1 1
      .github/AGENTS.md
  32. 42 2
      .github/workflows/ci-master.yml
  33. 29 12
      .github/workflows/ci.yml
  34. 3 1
      .github/workflows/expected-filenames.yml
  35. 10 1
      .github/workflows/sandbox.yml
  36. 2 2
      README.i18n.yaml
  37. 12 0
      README.md
  38. 12 0
      README.zh.md
  39. 1 1
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  40. 3 0
      apps/web/tests/expected/skill-invocation-policy/preview.expected.md
  41. 6 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  42. 6 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  43. 15 7
      apps/web/tests/present.e2e.ts
  44. 2 9
      apps/web/tests/produced-file-mentions.e2e.ts
  45. 80 3
      apps/web/tests/skill-invocation-policy.e2e.ts
  46. 24 1
      apps/web/tests/skill-user-invoke.e2e.ts
  47. 2 2
      docs/config-catalog.i18n.yaml
  48. 1 1
      docs/config-catalog.md
  49. 1 1
      docs/config-catalog.zh.md
  50. 2 2
      docs/event-producer-consumer.i18n.yaml
  51. 6 6
      docs/event-producer-consumer.md
  52. 6 6
      docs/event-producer-consumer.zh.md
  53. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  54. 1 1
      docs/subsystems/llm-streaming.md
  55. 1 1
      docs/subsystems/llm-streaming.zh.md
  56. 2 2
      docs/subsystems/skills.i18n.yaml
  57. 2 4
      docs/subsystems/skills.md
  58. 2 4
      docs/subsystems/skills.zh.md
  59. 0 1
      package.json
  60. 2 2
      packages/api/session-controller/README.i18n.yaml
  61. 2 0
      packages/api/session-controller/README.md
  62. 2 0
      packages/api/session-controller/README.zh.md
  63. 1 0
      packages/api/session-controller/src/skill-catalog.ts
  64. 2 0
      packages/api/session-controller/src/types.ts
  65. 2 0
      packages/api/session-controller/tests/session-skills.host.spec.ts
  66. 2 2
      packages/boot/app-boot/README.i18n.yaml
  67. 1 1
      packages/boot/app-boot/README.md
  68. 1 1
      packages/boot/app-boot/README.zh.md
  69. 4 9
      packages/boot/app-boot/src/profile.ts
  70. 2 2
      packages/client/ui-chat/README.i18n.yaml
  71. 6 0
      packages/client/ui-chat/README.md
  72. 6 0
      packages/client/ui-chat/README.zh.md
  73. 4 2
      packages/client/ui-chat/package.json
  74. 6 0
      packages/client/ui-chat/src/client/apply.ts
  75. 3 2
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  76. 2 1
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  77. 5 3
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  78. 4 0
      packages/client/ui-chat/src/client/contract/slots.ts
  79. 16 0
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  80. 3 1
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  81. 3 1
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  82. 3 0
      packages/client/ui-chat/tsconfig.json
  83. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  84. 2 0
      packages/client/ui-conversation/README.md
  85. 2 0
      packages/client/ui-conversation/README.zh.md
  86. 6 0
      packages/client/ui-conversation/src/client/contract/input.ts
  87. 3 12
      packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css
  88. 2 1
      packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx
  89. 20 9
      packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
  90. 33 0
      packages/client/ui-conversation/src/client/input/editor/reference-activation.ts
  91. 4 12
      packages/client/ui-conversation/src/client/input/editor/text-ref.ts
  92. 3 0
      packages/client/ui-conversation/src/client/input/facade.ts
  93. 64 0
      packages/client/ui-conversation/tests/reference-activation.client.spec.ts
  94. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  95. 4 4
      packages/client/ui-deliverables/README.md
  96. 4 4
      packages/client/ui-deliverables/README.zh.md
  97. 4 10
      packages/client/ui-deliverables/src/client/index.ts
  98. 0 2
      packages/client/ui-deliverables/src/client/locales.ts
  99. 5 7
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  100. 13 3
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 0c03884bba67dab6e2e38f96ce2e874ed62ba00f
-2026-08-28-subprocess-native-containment.zh.md: b7850e1c4fee06dfeb1a05c968d132de5994686f
+2026-08-28-subprocess-native-containment.md: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84
+2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d

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

@@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 
@@ -54,8 +54,9 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including an exited launcher with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
+- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
 - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
+- A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
 - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
 - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.
@@ -76,6 +77,8 @@ This note owns the current native-containment mechanism. It partially updates th
 
 **Recover a failed native launch by replaying the command.** Rejected because an ambiguous failure may occur after target execution and replay can therefore execute the command twice.
 
+**Infer startup failure from an unconsumed request alone.** Rejected because abort, timeout, or disposal can terminate the launcher before request consumption. Matching an observed signal to an owner-issued termination preserves cancellation without hiding an unrelated bootstrap exit or recorded pre-exec failure.
+
 ## Consequences
 
 Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux manager check and scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement.

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

@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 
@@ -54,8 +54,9 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 launcher 退出且 request 未消费)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
+- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
+- 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。
@@ -76,6 +77,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 **在 native launch 失败后重放命令。**不予采用,因为含糊 failure 可能发生在 target 已经执行之后,重放因此可能把命令执行两次。
 
+**仅从未消费的 request 推断启动失败。** 拒绝,因为 abort、timeout 或 dispose 可能在 request 消费前终止 launcher。将实际观察到的信号与 owner 请求的终止信号匹配,既能保留取消结果,也不会隐藏无关的 bootstrap 退出或已记录的 pre-exec failure。
+
 ## Consequences
 
 受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一次 Linux manager 检查与 scope/request,或一套 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。

+ 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/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml

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

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek image-token estimator on the v41 calculator
+
+Status: implemented
+
+English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md)
+
+## Problem
+
+`deepSeekImageTokens()` in `llm-deepseek` ported the provider's published image-token calculator in its `v4` configuration: a 384×384 scale-up floor, a 384-token cap, an 8:1 width clamp, a grid layout that adds a row for odd row counts and parity corrections, and a pad-to-4 alignment charged at its worst case. The provider's Vision guide now documents a different projection for the current Flash model: images below roughly 544×544 total pixels scale up, larger images scale down to roughly 1300×1300 total pixels, and one image costs at most 1024 tokens. The published calculator carries this as a `v41` configuration and the docs page instantiates that one. The old port underprices an 800×800 request image by 73 tokens, which can delay automatic compaction in sessions containing these images. The error depends on dimensions: a 640×480 image is overestimated by 3 tokens.
+
+## Decision
+
+`image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged.
+
+The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged.
+
+## Alternatives considered
+
+**Keep both configurations and select by model id.** The provider states that requests to the retired `deepseek-v4-flash-vision-exp` id are served by the current Flash model, so no reachable route prices under the old configuration. Two configurations would keep dead branches and their tests alive.
+
+**Keep the generic class with the `isNLayout`, pad, and ratio-clamp switches.** A one-configuration port has fewer unreachable branches to exclude from coverage and states the shipped rule directly; a future provider revision changes this one module and its pinned vectors either way.
+
+**Raise the harness pixel budget in the same change.** The provider now accepts roughly 1300×1300 total pixels per image, so the harness's 640,000-pixel projection discards detail the model could use. That is a request-content change with its own snapshot impact, separate from pricing what is actually sent.
+
+## Consequences
+
+An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek 图片 token 估算器改用 v41 计算器
+
+Status: implemented
+
+[English](2026-09-10-deepseek-image-token-calculator-v41.md) | 中文
+
+## 问题
+
+`llm-deepseek` 中的 `deepSeekImageTokens()` 移植的是提供方公开图片 token 计算器的 `v4` 配置:384×384 放大下限、384 token 上限、8:1 宽度钳制、奇数行数额外加一行并做奇偶校正的网格布局,以及按最坏情况计价的 pad-to-4 对齐。提供方的图像理解指南现在为当前 Flash 模型记录了另一套投影规则:总像素小于约 544×544 的图片放大,更大的图片缩小到约 1300×1300 总像素,单张图片最多 1024 token。公开计算器以 `v41` 配置承载这套规则,文档页实例化的也是它。旧移植对一张 800×800 的请求图片低估 73 token,可能使包含这类图片的会话延迟触发自动压缩。误差取决于尺寸:一张 640×480 的图片会被高估 3 token。
+
+## 决策
+
+`image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。
+
+测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。
+
+## 备选方案
+
+**保留两套配置并按模型 id 选择。** 提供方说明发往已下线的 `deepseek-v4-flash-vision-exp` 的请求由当前 Flash 模型承接,所以没有可达路由会按旧配置计价。两套配置会保留死分支及其测试。
+
+**保留带 `isNLayout`、pad 和宽高比钳制开关的通用类。** 单配置移植需要从覆盖率中排除的不可达分支更少,并直接陈述已上线的规则;提供方未来再修订时,两种写法都只改这一个模块和它固定的向量。
+
+**在同一改动中提高 harness 像素预算。** 提供方现在每张图接受约 1300×1300 总像素,harness 的 640,000 像素投影会丢弃模型本可利用的细节。那是请求内容的改动,有自己的快照影响,与为实际发送内容计价是两件事。
+
+## 后果
+
+800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。

+ 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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 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

+ 2 - 2
README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write README.md
-README.md: 9f89db3d4502dea4a0d181799164f304d4976740
-README.zh.md: aa66ef1d24a5e2165859e9337273d807dff3d070
+README.md: 36adfe913902e75ce0673adc2cb5cb692151a291
+README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6

+ 12 - 0
README.md

@@ -56,6 +56,18 @@ Start with the [development guide](docs/development.md) and [architecture docume
 
 For agents, follow [AGENTS.md](AGENTS.md).
 
+## Citation
+
+```bibtex
+@misc{deepseek-harness2026,
+  title={DeepSeek Harness: Everything is a Plugin},
+  author={DeepSeek-AI},
+  year={2026},
+  publisher={GitHub},
+  howpublished={\url{https://github.com/deepseek-ai/deepseek-harness}},
+}
+```
+
 ## License
 
 [MIT](LICENSE)

+ 12 - 0
README.zh.md

@@ -77,6 +77,18 @@ pnpm dsh web
 
 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。
 
+## 引用
+
+```bibtex
+@misc{deepseek-harness2026,
+  title={DeepSeek Harness: Everything is a Plugin},
+  author={DeepSeek-AI},
+  year={2026},
+  publisher={GitHub},
+  howpublished={\url{https://github.com/deepseek-ai/deepseek-harness}},
+}
+```
+
 ## 许可证
 
 [MIT](LICENSE)

+ 1 - 1
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -153,7 +153,7 @@
 - paragraph:
   - text: Wrote
   - code:
-    - button "Open site/report.html": report.html
+    - button "Open site/report.html in sidebar": report.html
   - text: plus two
   - code: style.css
   - text: copies;

+ 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":

+ 15 - 7
apps/web/tests/present.e2e.ts

@@ -123,6 +123,21 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
       await row.waitFor()
       expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2)
       expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt')
+      const beforePreview = (await opened()).length
+      const column = page.locator('[data-rightbar-col]')
+      for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
+        const mention = page.locator('code').getByRole('button', { name: `Open ${name} in sidebar`, exact: true })
+        await mention.click()
+        const preview = column.locator('[data-document-preview]')
+        await expect.poll(() => preview.getAttribute('data-textpreview-url'))
+          .toBe(`dsh-resource://file/session/${sessionId}/${encodeURIComponent(name)}`)
+        await preview.getByText(content, { exact: true }).waitFor()
+        await mention.click()
+        expect(await column.locator('[data-dockkit-tab]').filter({ hasText: name }).count()).toBe(1)
+      }
+      expect(await opened()).toHaveLength(beforePreview)
+      expect(downloads).toEqual([])
+      await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
       const beforeReveal = (await opened()).length
       await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
       const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
@@ -143,13 +158,6 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
         expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
       }
     }
-    const count = (await opened()).length
-    const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
-    await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
-    await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
-    expect((await openedResponse).status()).toBe(204)
-    expect(await opened()).toHaveLength(count + 1)
-    expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' })
     expect(downloads).toEqual([])
     const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
     expect(response.status()).toBe(200)

+ 2 - 9
apps/web/tests/produced-file-mentions.e2e.ts

@@ -1,11 +1,4 @@
-// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
-// a built write turn (zero model calls) whose closing message names the written
-// file three ways: by unique basename (links), ambiguously (stays inert), and
-// as a file the turn never touched (stays inert). Package tests cover the
-// resolver in isolation; only the assembled application shows a real write's
-// locations reaching the prose as an opener. The click itself is not driven
-// here: it hands the path to the Host's opener, which would launch a real
-// application on the machine running the suite (the produced-files restraint).
+/** Web e2e coverage for unique, ambiguous, and unknown inline file references. */
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -156,7 +149,7 @@ describe('web e2e: inline-code mentions of produced files', () => {
     const mentions = page.locator('[class*="markdown"] code button')
     await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
     expect(await mentions.first().innerText()).toBe('report.html')
-    expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
+    expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html in sidebar')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
     // The turn still ends with its produced-files row (all three writes).
     expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)

+ 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: eaf0a8ceafe621f6c82dab536dcceb77fbd66768
-config-catalog.zh.md: e82a15fd542fa091fbe76e179997a79b6e6fb6f2
+config-catalog.md: ab444050bed07c29892b57a600687fad7eb61e29
+config-catalog.zh.md: 72f05bd96702a8675ba817811369215a4215ecf1

+ 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/llm-streaming.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/llm-streaming.md
-llm-streaming.md: cadfda5553ca5d5e0df78f40b6a7a75ef7cc71a6
-llm-streaming.zh.md: 5f73d544459268ce83a30218b710dd7c29f7a614
+llm-streaming.md: 77d3313b3c1a025f756e984453bd041abd7b0534
+llm-streaming.zh.md: cac7897ab412f45bd5b5ab20c6460644f18f4ff2

+ 1 - 1
docs/subsystems/llm-streaming.md

@@ -249,7 +249,7 @@ interface LlmFailure {
 
 ## Request-image pricing
 
-An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published v4 vision accounting, while provider usage remains the authoritative anchor for completed requests.
+An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests.
 
 ```ts type-equiv
 /**

+ 1 - 1
docs/subsystems/llm-streaming.zh.md

@@ -251,7 +251,7 @@ interface LlmFailure {
 
 ## 请求图片定价
 
-提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的 v4 视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
+提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
 
 ```ts type-equiv
 /**

+ 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

@@ -184,7 +184,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-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4
-README.zh.md: dadb1a9a581d21af02e0f1239b0f7801148269a1
+README.md: ac0e7a4508bdf2b3705491d85837e84f5911d727
+README.zh.md: f0ce8691051aae4fbf664048c2f6e322db47684a

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package renders the deliverables row a finished turn ends with — the files the mutation tools created or modified — and links matching inline-code references in the closing prose, so a mentioned file opens in the Host. The vocabulary comes from the mutation tools' own `locations`, never from the closing prose — a produced file is listed whether or not the model remembered to name it. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, row, and prose links together.
+This package renders the deliverables row a finished turn ends with — the files the mutation tools created or modified — and links matching inline-code references in the closing prose, so a mentioned file opens in the right Sidebar. The linked paths come from successful mutations and explicit deliveries, never from the closing prose — a produced file is listed whether or not the model remembered to name it. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, row, and prose links together.
 
 ## Table of Contents
 
@@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
 
-The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply.
+The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
 
 The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
 
@@ -40,7 +40,7 @@ The “Files changed” row lists successful file-tool mutations; final file del
 
 ### Inline-code links
 
-The closing prose carries the same vocabulary: an inline-code token resolves by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention can never open the wrong file. A resolved mention keeps its code chip and takes the markdown sheet's link language, with the full path as its title.
+The closing prose links produced or delivered paths: an inline-code token resolves by exact path, or by being exactly the basename of exactly one such path — a basename two paths share stays inert rather than guessing, so a mention can never open the wrong file. A resolved mention keeps its code chip and takes the markdown sheet's link language, with the full path as its title.
 
 -----
 
@@ -95,7 +95,7 @@ The section is static at first-party order 9000 for the lifetime of the package
 These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
-- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening.
+- **Terminal-created files require explicit delivery** — call `present` to make them available as delivery cards and clickable references.
 - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
 - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced.
 

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

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包渲染已完成轮次末尾的产出文件行——列出修改工具创建或修改的文件——并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在 Host 中打开。词表来自修改工具自身的 `locations`,而非收尾正文——无论模型是否记得点名,产出文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、文件行与正文链接。
+本包渲染已完成轮次末尾的产出文件行——列出修改工具创建或修改的文件——并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。链接路径来自成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,产出文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、文件行与正文链接。
 
 ## 目录
 
@@ -30,7 +30,7 @@ kind: "package-reference"
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
@@ -40,7 +40,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 行内代码链接
 
-收尾正文承载同一份词表:行内代码 token 按精确路径解析,或当它恰好等于某条产出路径的 basename 且该路径唯一时解析——两条路径共享同一 basename 时保持不可点击而不作猜测,因此提及绝不打开错误的文件。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式,完整路径作为其 `title`。
+收尾正文链接产出或已交付的路径:行内代码 token 按精确路径解析,或当它恰好等于其中某条路径的 basename 且该路径唯一时解析——两条路径共享同一 basename 时保持不可点击而不作猜测,因此提及绝不打开错误的文件。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式,完整路径作为其 `title`。
 
 -----
 
@@ -95,7 +95,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开
+- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和可点击引用
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
 

+ 4 - 10
packages/client/ui-deliverables/src/client/index.ts

@@ -1,7 +1,7 @@
 /**
  * Deliverables plugin, browser half: registers the produced-files row into
  * the chat view's turn-tail chain, and provides the `chatFileMentions`
- * service that links inline-code mentions of produced files in the closing
+ * service that links inline-code mentions of produced or delivered files in the closing
  * prose. All policy lives here — the supported mutation calls, mention
  * matching, chip cap, and copy — so
  * composing this plugin out of cordis.yml removes both surfaces entirely;
@@ -65,18 +65,12 @@ export function apply(ctx: ClientContext): void {
   // via ctx.get, so its absence — this plugin composed out — is the off state.
   const t = ctx.locale.bind(NS)
   const mentions: ChatFileMentions = {
-    forClosing(owner, sessionId) {
-      // Same claim test the turn-tail chain entry runs: no produced files,
-      // no vocabulary — the two surfaces agree by construction.
+    forClosing(owner) {
       const paths = selectProducedFiles(owner)
       const presented = presentedForClosing(owner)
       if (paths === null && presented.length === 0) return undefined
-      const deliveries = new Map(presented.map(file => [file.path, file]))
-      return producedFileMentions([...new Set([...paths ?? [], ...deliveries.keys()])], (path) => {
-        const file = deliveries.get(path)
-        if (file === undefined) owner.openFile(path)
-        else void opener.open(sessionId, file.seq, file.index)
-      }, path => t(deliveries.has(path) ? 'presented.open' : 'produced.open', { name: path }))
+      return producedFileMentions([...new Set([...paths ?? [], ...presented.map(file => file.path)])], owner.openFile,
+        path => t('presented.previewButton', { name: path }))
     },
   }
   ctx.provide('chatFileMentions', mentions)

+ 0 - 2
packages/client/ui-deliverables/src/client/locales.ts

@@ -38,7 +38,6 @@ export const zh = {
   'row.error': '交付失败',
   'row.stopped': '已中断',
   'row.inspect': '查看调用',
-  'presented.open': '在默认程序中打开 {name}',
   'produced.label': '本轮文件改动',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
@@ -80,7 +79,6 @@ export const en: Record<DeliverablesKey, string> = {
   'row.error': 'Delivery failed',
   'row.stopped': 'Interrupted',
   'row.inspect': 'Inspect call',
-  'presented.open': 'Open {name} in default app',
   'produced.label': 'Files changed',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',

+ 5 - 7
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -230,12 +230,10 @@ export function presentedForClosing(owner: TurnTailOwnerProps): PresentedPath[]
 export { basename } from '../presented.ts'
 
 /**
- * File-mention vocabulary over one turn's produced paths, for the closing
- * message's prose: an inline-code token opens the file it names. A token
- * resolves by exact path, or by being exactly the basename of exactly one
- * produced path — a basename two paths share stays inert rather than
- * guessing, so a mention link can never open the wrong file or 404.
- * @param paths - The turn's produced paths (tool order, already deduped).
+ * Resolves inline-code references against one turn's produced or delivered
+ * paths. Exact paths resolve directly; a basename resolves only when exactly
+ * one supplied path has that basename. Ambiguous and unknown tokens stay inert.
+ * @param paths - The turn's produced or delivered paths, already deduplicated.
  * @param openFile - The chat view's file opener.
  * @param label - Localizes the accessible open-label for a resolved path.
  * @returns The resolver MarkdownText consumes; the full path rides `title`,
@@ -255,7 +253,7 @@ export function producedFileMentions(
   }
 }
 
-/** The single produced path whose basename is exactly `value`, else undefined. */
+/** The single supplied path whose basename is exactly `value`, else undefined. */
 function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined {
   const matches = paths.filter(path => basename(path) === value)
   return matches.length === 1 ? matches[0] : undefined

+ 13 - 3
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -547,13 +547,23 @@ describe('plugin registration', () => {
     )
     const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
     const mentions = service?.forClosing(owner, SessionId('viewed-session'))
+    expect(mentions?.resolve('report.html')?.label).toBe('Open site/report.html in sidebar')
     mentions?.resolve('report.html')?.open()
     expect(opened).toEqual(['site/report.html'])
     const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
     vi.stubGlobal('fetch', fetcher)
-    const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3)
-    service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open()
-    expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
+    const preview = vi.fn<(path: string) => void>()
+    for (const produced of [[], [{ path: 'out/report.docx', seq: 1 }]]) {
+      const delivered = tailOwner({ produced, presented: [{ path: 'out/report.docx', seq: 2, index: 0 }] }, 3, preview)
+      const mentions = service?.forClosing(delivered, SessionId('child-session'))
+      for (const text of ['report.docx', 'out/report.docx']) {
+        const mention = mentions?.resolve(text)
+        expect(mention?.label).toBe('Open out/report.docx in sidebar')
+        mention?.open()
+      }
+    }
+    expect(preview.mock.calls).toEqual(Array.from({ length: 4 }, () => ['out/report.docx']))
+    expect(fetcher).not.toHaveBeenCalled()
     const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected
     fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
     await face.reloadPresentedHost()

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است