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

feat(web): add file and session references

Yichen Jiang преди 2 месеца
родител
ревизия
ad3632f122
променени са 100 файла, в които са добавени 2281 реда и са изтрити 621 реда
  1. 3 3
      .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml
  2. 6 6
      .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
  3. 6 6
      .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md
  4. 3 3
      .agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml
  5. 5 3
      .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
  6. 5 3
      .agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md
  7. 3 3
      .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml
  8. 4 4
      .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md
  9. 4 4
      .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md
  10. 6 0
      .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml
  11. 49 0
      .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md
  12. 49 0
      .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md
  13. 14 3
      apps/cli/cordis.yml
  14. 5 1
      apps/cli/package.json
  15. 69 9
      apps/web/tests/slash-flow.snapshot.ts
  16. 9 1
      docs/capability-seams.md
  17. 21 2
      docs/config-catalog.md
  18. 8 8
      docs/cordis-catalog/events.md
  19. 20 1
      docs/cordis-catalog/services.md
  20. 10 10
      docs/event-producer-consumer.md
  21. 45 31
      docs/module-graph.md
  22. 1 0
      packages/client/connection/src/client/api.ts
  23. 32 0
      packages/client/connection/src/client/fixture.ts
  24. 1 0
      packages/client/connection/src/client/index.ts
  25. 5 0
      packages/client/connection/tests/fake-api.ts
  26. 5 0
      packages/client/runtime/src/client/sessions/conversation.ts
  27. 9 2
      packages/client/runtime/src/client/sessions/fold-adapter.ts
  28. 7 2
      packages/client/runtime/src/client/sessions/session.ts
  29. 5 0
      packages/client/runtime/tests/fake-api.ts
  30. 33 0
      packages/client/runtime/tests/fold-adapter.spec.ts
  31. 7 1
      packages/client/runtime/tests/session.spec.ts
  32. 1 1
      packages/client/tsdown.client.ts
  33. 3 3
      packages/client/ui-conversation/README.i18n.yaml
  34. 2 0
      packages/client/ui-conversation/README.md
  35. 2 0
      packages/client/ui-conversation/README.zh.md
  36. 17 3
      packages/client/ui-conversation/src/client/chat/MessageItem.module.css
  37. 44 15
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  38. 2 7
      packages/client/ui-conversation/src/client/input/contract.ts
  39. 38 16
      packages/client/ui-conversation/src/client/input/facade.ts
  40. 23 21
      packages/client/ui-conversation/src/client/input/hub.ts
  41. 15 20
      packages/client/ui-conversation/src/client/input/machine.ts
  42. 15 8
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  43. 46 0
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  44. 4 4
      packages/client/ui-conversation/tests/input-bar.spec.tsx
  45. 12 8
      packages/client/ui-conversation/tests/input-machine.spec.ts
  46. 8 5
      packages/client/ui-conversation/tests/input-matrix.spec.tsx
  47. 116 0
      packages/client/ui-conversation/tests/input-reference-submit.spec.ts
  48. 7 3
      packages/client/ui-conversation/tests/input-scenarios.spec.tsx
  49. 2 2
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  50. 3 3
      packages/client/ui-reference/README.i18n.yaml
  51. 25 0
      packages/client/ui-reference/README.md
  52. 25 0
      packages/client/ui-reference/README.zh.md
  53. 7 4
      packages/client/ui-reference/package.json
  54. 115 0
      packages/client/ui-reference/src/client/index.ts
  55. 1 1
      packages/client/ui-reference/src/index.ts
  56. 4 4
      packages/client/ui-reference/src/invariant.ts
  57. 325 0
      packages/client/ui-reference/tests/browser-plugin.spec.ts
  58. 4 1
      packages/client/ui-reference/tsconfig.json
  59. 3 0
      packages/client/ui-reference/tsdown.config.ts
  60. 3 3
      packages/client/ui-slash/README.i18n.yaml
  61. 5 3
      packages/client/ui-slash/README.md
  62. 5 3
      packages/client/ui-slash/README.zh.md
  63. 2 0
      packages/client/ui-slash/package.json
  64. 14 0
      packages/client/ui-slash/src/client/MenuView.module.css
  65. 24 20
      packages/client/ui-slash/src/client/MenuView.tsx
  66. 12 2
      packages/client/ui-slash/src/client/controller.ts
  67. 5 2
      packages/client/ui-slash/src/core/contract.ts
  68. 19 6
      packages/client/ui-slash/src/core/detect.ts
  69. 10 2
      packages/client/ui-slash/src/types.ts
  70. 11 0
      packages/client/ui-slash/tests/core-detect.spec.ts
  71. 1 0
      packages/client/ui-slash/tests/core-menu.spec.ts
  72. 26 0
      packages/client/ui-slash/tests/menu-view.spec.tsx
  73. 3 0
      packages/client/ui-slash/tsconfig.json
  74. 0 31
      packages/client/ui-subagent/README.md
  75. 0 31
      packages/client/ui-subagent/README.zh.md
  76. 0 58
      packages/client/ui-subagent/src/client/index.ts
  77. 0 6
      packages/client/ui-subagent/src/css-modules.d.ts
  78. 0 145
      packages/client/ui-subagent/tests/browser-plugin.spec.ts
  79. 0 3
      packages/client/ui-subagent/tsdown.config.ts
  80. 6 0
      packages/context/file-reference-local/README.i18n.yaml
  81. 45 0
      packages/context/file-reference-local/README.md
  82. 45 0
      packages/context/file-reference-local/README.zh.md
  83. 53 0
      packages/context/file-reference-local/package.json
  84. 140 0
      packages/context/file-reference-local/src/index.ts
  85. 30 0
      packages/context/file-reference-local/src/invariant.ts
  86. 17 69
      packages/context/file-reference-local/src/search.ts
  87. 12 0
      packages/context/file-reference-local/tests/invariant.spec.ts
  88. 4 2
      packages/context/file-reference-local/tests/search.spec.ts
  89. 161 0
      packages/context/file-reference-local/tests/service.spec.ts
  90. 33 0
      packages/context/file-reference-local/tsconfig.json
  91. 6 0
      packages/context/file-reference/README.i18n.yaml
  92. 22 0
      packages/context/file-reference/README.md
  93. 22 0
      packages/context/file-reference/README.zh.md
  94. 44 0
      packages/context/file-reference/package.json
  95. 55 0
      packages/context/file-reference/src/grammar.ts
  96. 51 0
      packages/context/file-reference/src/index.ts
  97. 30 0
      packages/context/file-reference/src/invariant.ts
  98. 12 0
      packages/context/file-reference/tests/invariant.spec.ts
  99. 21 0
      packages/context/file-reference/tsconfig.json
  100. 14 0
      packages/cordis/tool-cordis/src/api-catalog.ts

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml

@@ -1,6 +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
-2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b
-2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
+2026-07-25-web-command-surfaces-and-assembly.md: 18ee22b8b359e7f2af15a69d2917774e8a9b609d
+2026-07-25-web-command-surfaces-and-assembly.zh.md: e08c233662e16c06b3a0d352e2db9dc5e119e948

+ 6 - 6
.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md

@@ -1,10 +1,10 @@
-# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-subagent)
+# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-reference)
 
 Status: implemented
 
 English | [中文](2026-07-25-web-command-surfaces-and-assembly.zh.md)
 
-> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the two skill / subagent reference sources, and fixture command routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md).
+> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the skill and unified file/session reference sources, and fixture routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md). Structured reference semantics are owned by [Web file and session references](../feature/2026-07-27-web-file-and-session-references.md).
 
 ## Problem
 
@@ -29,7 +29,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
 ### Reference sources (seeing only projections plus their own apply closures, on the root ctx)
 
 - **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
-- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream).
+- **ui-reference**: one `@` source starts Host-backed file and session discovery together, renders files first, keeps quoted tokens file-only, continues directory picks, and represents sessions as atomic chips backed by canonical Host mentions. Host-side snapshot preparation and failure-preserving ordinary submission are specified by the owning [reference note](../feature/2026-07-27-web-file-and-session-references.md).
 
 ### Fixture command routing and assembly
 
@@ -38,7 +38,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
 
 ### Assembly-level acceptance: the slash-flow snapshot
 
-`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden.
+`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → completing a directory and file through `@` → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. A fixture branch selects an atomic `@session` chip. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden.
 
 ## Alternatives considered
 
@@ -47,11 +47,11 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
 | Inline prompt dispatch (command text riding the message into the host for parsing) | Conflates the command and message planes; command execution being independent of the message queue is existing host semantics |
 | A bridge materializing skills as commands | Skills have their own directory; N registrations would be a detour; the tag form naturally avoids the command plane |
 | A `skill.invoke` RPC | The host has no such operation; skill references are plain text riding prompts |
-| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); text-as-truth plus structured occurrence records suffices |
+| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); canonical mention text plus atomic composer state and Host preparation preserve identity without it |
 | Client packages self-reporting command directories | The host is the single source of truth; the client only reads descriptors, with `commands-changed` pushing invalidation |
 | The `requires: 'none' \| 'agent'` discriminant axis (an agentless directory + dual-addressed queries) | With sessions always agent-backed, the amphibious command has no owner; the whole axis reverts to master's shape, to be reopened on real demand |
 | Dedicated commandresult / commandpanel slots | Results go through notices; the popup shell is a skeleton-internal overlay; rich result cards sit in the ledger |
-| An agent-type directory as the `@` source | No type registry exists; the live-session snapshot already covers it |
+| A browser-side agent directory as the `@` source | Session-reference candidates are a Host capability with stable ids and persisted source surfaces; a browser-only running-child roster cannot provide them |
 | A PickAction/EnterCommand class family (class-inheritance pick products) | Cross-package runtime values break client bundle purity; pure data interfaces plus closure methods are equivalent |
 
 ## Consequences

+ 6 - 6
.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md

@@ -1,10 +1,10 @@
-# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-subagent)
+# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-reference)
 
 Status: implemented
 
 [English](2026-07-25-web-command-surfaces-and-assembly.md) | 中文
 
-> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill / subagent 两个引用源、fixture 命令路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。
+> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill(技能)与统一的文件/会话引用源,以及 fixture(测试前置数据)路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。结构化引用语义由 [Web 文件与会话引用](../feature/2026-07-27-web-file-and-session-references.md)说明。
 
 ## 问题
 
@@ -29,7 +29,7 @@ Status: implemented
 ### 引用源(只见投影 + 自家 apply 闭包的 root ctx)
 
 - **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
-- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。
+- **ui-reference**:同一个 `@` source 会同时启动宿主支持的文件与会话发现,先渲染文件;带引号的 token 只显示文件;选择目录后继续补全;会话则表示为由宿主规范提及标记支撑的原子 chip。宿主侧快照准备和失败时保留内容的普通提交由对应的[引用 note](../feature/2026-07-27-web-file-and-session-references.md)定义。
 
 ### fixture 命令路由与装配
 
@@ -38,7 +38,7 @@ Status: implemented
 
 ### 装配级验收:slash-flow 快照
 
-`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配转录):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。
+`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配后的 transcript(文本记录)):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → 通过 `@` 补全一个目录和文件 → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。fixture 分支会选择一个原子的 `@session` chip。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。
 
 ## Alternatives considered
 
@@ -47,11 +47,11 @@ Status: implemented
 | prompt 内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 |
 | skill 物化为 command 的桥 | skill 自有目录;N 笔注册是绕路;标签形式天然避开命令平面 |
 | `skill.invoke` RPC | host 无此操作;skill 引用是随 prompt 的普通文本 |
-| 新 ContentBlock 引用类型 | 全链路成本(adapter/UI/compaction);文本即真身 + 结构化 occurrence 记录已足够 |
+| 新 ContentBlock 引用类型 | 全链路成本(适配器/UI/压缩);规范提及文本、原子 composer 状态与宿主准备无需该类型也能保留身份 |
 | client 各包自报命令目录 | host 是唯一真源;client 只读 descriptor,`commands-changed` 推失效 |
 | `requires: 'none' \| 'agent'` 判别轴(agentless 目录 + 双址查询) | 会话恒 agent-backed 后两栖命令无 owner;整轴回退 master 形状,待真需求重开 |
 | 专用 commandresult / commandpanel 坑位 | 结果走 notice;popup 壳是骨架内浮层;富结果卡入台账 |
-| agent-type 目录做 `@` 源 | 无类型注册表;live-session 快照已覆盖 |
+| 浏览器侧 agent 目录做 `@` 源 | 会话引用候选是宿主功能,具有稳定 id 和持久化的源表层;仅存在于浏览器中的运行中子会话 roster 无法提供这些信息 |
 | PickAction/EnterCommand 类族(类继承 pick 产物) | 跨包运行时值破坏 client bundle 纯度;纯数据接口 + 闭包方法等价 |
 
 ## 后果

+ 3 - 3
.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml

@@ -1,6 +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
-2026-07-21-cross-session-references.md: fc084b36e7920a72efff0f363278d24eaebc4c69
-2026-07-21-cross-session-references.zh.md: fe4a876b5265fa7ad298adf3b829bcec70e878e8
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
+2026-07-21-cross-session-references.md: 0aca4606d27eab34102fef5bcd82a9565dc6599e
+2026-07-21-cross-session-references.zh.md: 3f1cf4d863898bae806fab698b095fd7fd82e1f8

+ 5 - 3
.agents/notes/implemented/feature/2026-07-21-cross-session-references.md

@@ -32,7 +32,9 @@ This preserves host driving semantics: TUI decides `send()` versus `steer()` fro
 
 ## Host adapters
 
-TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal.
+TUI combines session candidates with the shared `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal.
+
+Web exposes the same candidate and preparation semantics through `reference.sessions` and `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, then replay projects the logged display content and a compact session-source summary.
 
 The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services.
 
@@ -53,8 +55,8 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b
 
 ## Verification
 
-Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string.
+Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, Web wire preparation, failure-preserving Web submission, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. A keyless Web snapshot pins the assembled reference selection path.
 
 ## Consequences
 
-The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard TUI demo bundle mounts it explicitly and exposes its count and per-source byte limits in its config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant.
+The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard CLI composition mounts it explicitly for both TUI and Web and exposes its count and per-source byte limits in config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant.

+ 5 - 3
.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md

@@ -32,7 +32,9 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但
 
 ## 宿主适配器
 
-TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。
+TUI 把会话候选与共享的 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。
+
+Web 通过 `reference.sessions` 和 `session.prompt` 暴露相同的候选与准备语义,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。选择会话会创建由宿主生成的规范提及标记支撑的原子 chip。输入框会保留文本与 chip,直到准备和入队都成功;随后回放会投影日志中记录的显示内容和精简的会话来源摘要。
 
 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。
 
@@ -53,8 +55,8 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选
 
 ## 验证
 
-单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。
+单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失、Web 协议准备、失败时保留内容的 Web 提交,以及精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。无密钥 Web 快照固定装配后的引用选择路径。
 
 ## 后果
 
-新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI 演示组合包会显式挂载它,并在自身配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。
+新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 CLI(命令行界面)组合会为 TUI 和 Web 显式挂载它,并在配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。

+ 3 - 3
.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml

@@ -1,6 +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
-2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5
-2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md
+2026-07-23-tui-file-reference-autocomplete.md: 93fd09efc826ff29531e90138939df8b3254a99a
+2026-07-23-tui-file-reference-autocomplete.zh.md: 60dbfb577b11244f7dbece023333fff247cb1924

+ 4 - 4
.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md

@@ -10,9 +10,9 @@ The TUI offered structured `@session` references but no dependable way to discov
 
 ## Decision
 
-The TUI owns a bounded, cancellable host-workspace path index rooted at the active session's working directory. Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files.
+The shared `@deepseek-ai/dsh-file-reference-local` provider owns a bounded, cancellable host-workspace path index rooted at each active session's working directory. TUI consumes its search and grammar implementation directly, while Web reaches the same capability through the Host API as recorded in [Web file and session references](2026-07-27-web-file-and-session-references.md). Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files.
 
-Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the TUI contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations.
+Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the local provider contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations.
 
 Structured session mentions keep their existing snapshot preparation. Unlike files, a referenced session has no general model-facing retrieval tool, so reducing `@session` to a path-like label would make its content unreachable.
 
@@ -24,10 +24,10 @@ Structured session mentions keep their existing snapshot preparation. Unlike fil
 
 **Use the filesystem service's ordinary directory-list operation for discovery.** That seam is optimized for exact model-facing filesystem operations and may represent a remote namespace; recursive fuzzy indexing would multiply provider round trips and couple editor latency to tool policy. Host-side discovery keeps the terminal interaction local, while the documented namespace-alignment limitation remains explicit for non-local deployments.
 
-**Add a new cross-package file-search capability.** The TUI is the only current consumer and the behavior is editor presentation rather than a model capability, so a new interface, implementation, and consumer package set would split the seam prematurely.
+**Add a cross-package file-search capability before another consumer exists.** Rejected for the original TUI-only implementation because it would have split the seam prematurely. Web is now a second current consumer across a process boundary, so the later [Web reference decision](2026-07-27-web-file-and-session-references.md) introduces the interface / local implementation / consumer split and preserves this note's path-only semantics.
 
 ## Consequences
 
 Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip.
 
-Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion.
+Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align completion with the `read` namespace or supply a different provider. Shared-package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots, the Web snapshot, and the real Loader PTY smoke pin the visible completion flows.

+ 4 - 4
.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md

@@ -10,9 +10,9 @@ TUI 提供结构化的 `@session` 引用,但用户在编辑提示词时无法
 
 ## 决策
 
-TUI 维护一个有容量上限且可取消的主机工作区路径索引,以活跃会话的工作目录为根。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。
+共享的 `@deepseek-ai/dsh-file-reference-local` 提供方维护一个有容量上限且可取消的宿主工作区路径索引,以每个活跃会话的工作目录为根。TUI 直接消费其搜索和语法实现,Web 则通过宿主 API 使用同一功能,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。
 
-选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,TUI 会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。
+选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,本地提供方会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。
 
 结构化会话提及保留现有的快照准备方式。与文件不同,被引用的会话没有通用的模型侧检索工具;如果把 `@session` 简化为类似路径的标签,模型将无法获取其内容。
 
@@ -24,10 +24,10 @@ TUI 维护一个有容量上限且可取消的主机工作区路径索引,以
 
 **使用文件系统服务的常规目录列表操作进行发现。** 该 seam 针对面向模型的准确文件系统操作进行了优化,并且可能表示远程命名空间;递归模糊索引会增加提供方往返次数,并使编辑器延迟与工具策略耦合。主机侧发现让终端交互保留在本地,同时文档仍明确说明非本地部署中的命名空间对齐限制。
 
-**新增跨包的文件搜索功能。** TUI 是目前唯一的消费方,而且该行为属于编辑器呈现而非模型功能;新增一组接口、实现和消费方包会过早拆分这条 seam。
+**在出现另一个消费方之前新增跨包的文件搜索功能。** 原始实现只有 TUI 消费,因此不予采纳:该方案会过早拆分这条 seam。Web 现已成为跨进程边界的第二个当前消费方,因此后续的 [Web 引用决策](2026-07-27-web-file-and-session-references.md)引入接口/本地实现/消费方拆分,并保留本记录仅使用路径的语义。
 
 ## 影响
 
 用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。
 
-补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。
+补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让补全与 `read` 命名空间对齐,否则需要提供不同的提供方。共享包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照、Web 快照与真实 Loader PTY 冒烟测试固定可见的补全流程。

+ 6 - 0
.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.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-07-27-web-file-and-session-references.md
+2026-07-27-web-file-and-session-references.md: 71648a6ddbcffc7e700db1b0ce2135bf157e2bab
+2026-07-27-web-file-and-session-references.zh.md: 05c0896700ae55c64b94c0b37a00a38127c97f1f

+ 49 - 0
.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md

@@ -0,0 +1,49 @@
+# Agent Note: Web file and session references
+
+Status: implemented
+
+English | [中文](2026-07-27-web-file-and-session-references.zh.md)
+
+## Problem
+
+The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. The TUI already offered workspace-path discovery and structured cross-session snapshots, so Web needed the same user semantics without scanning the Host filesystem in the browser, binding session identity to a display label, or clearing a draft before Host-side snapshot preparation succeeded.
+
+## Decision
+
+Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Host lookups concurrently and preserves the TUI ordering of files before sessions; non-selectable `文件与文件夹` and `Session 对话` headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other.
+
+The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the Host RPC. The TUI imports the same search and grammar implementation instead of retaining a private copy. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash.
+
+A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before enqueue, then passes the prepared content and contexts in one agent operation. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure enqueue nothing.
+
+The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders each metadata-confirmed session label as a reference chip even when following text is adjacent, plus a compact session-source summary instead of the snapshot JSON baked into model content.
+
+## Reference transaction
+
+```text
+type @ → parallel file/session RPCs → pick path text or canonical session chip
+       → serialize draft → Host parses and prepares all sessions → enqueue once
+       ↘ any pre-enqueue failure: retain the unchanged editable draft
+```
+
+File lookup is advisory and cancellable; selection itself performs no read. Session preparation is authoritative and all-or-nothing because the source snapshot must be fixed before the target inbox accepts the message.
+
+## Alternatives considered
+
+**Keep file completion TUI-private.** Rejected after Web became a second current consumer; duplicate grammar, ranking, bounds, and invalidation would drift, while browser-side code cannot safely access the Host workspace.
+
+**Scan files through ordinary filesystem-tool RPCs.** Rejected because recursive fuzzy discovery is editor latency work, not a model-facing exact filesystem operation, and would couple the menu to tool policy and provider round trips.
+
+**Eagerly attach selected file contents.** Rejected because selection would spend context before relevance is known and bypass the logged, auditable `read` call/result sequence.
+
+**Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display.
+
+**Clear the composer before the RPC settles.** Rejected because a failed preparation would lose the only editable copy of the request and visually claim acceptance that never occurred.
+
+## Verification
+
+Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition.
+
+## Consequences
+
+Web and TUI now share `@file` discovery semantics and the same structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages and one Host RPC domain, but keeps browser bundles free of Node APIs and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`.

+ 49 - 0
.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md

@@ -0,0 +1,49 @@
+# Agent Note: Web 文件与会话引用
+
+Status: implemented
+
+[English](2026-07-27-web-file-and-session-references.md) | 中文
+
+## 问题
+
+Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。TUI 已经提供工作区路径发现和结构化跨会话快照,因此 Web 需要提供相同的用户语义,同时避免在浏览器中扫描宿主文件系统、把会话身份绑定到显示标签,或者在宿主侧快照准备成功前清除草稿。
+
+## 决策
+
+Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项宿主查询,并保留 TUI 中文件排在会话之前的顺序;不可选择的 `文件与文件夹` 和 `Session 对话` 标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。
+
+文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费宿主 RPC。TUI 直接导入同一套搜索和语法实现,不再保留私有副本。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。
+
+选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在入队前调用 `ctx.sessionReferences.prepare()`,随后以一次 agent 操作传入准备后的内容和上下文。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会让消息入队。
+
+输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把元数据确认的每个会话标签渲染为引用 chip,即使后续文本与标签直接相邻也如此,并显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。
+
+## 引用事务
+
+```text
+type @ → parallel file/session RPCs → pick path text or canonical session chip
+       → serialize draft → Host parses and prepares all sessions → enqueue once
+       ↘ any pre-enqueue failure: retain the unchanged editable draft
+```
+
+文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备具有权威性,并且必须全有或全无,因为目标收件箱接受消息前必须固定源快照。
+
+## 备选方案
+
+**文件补全仅保留在 TUI 内部。** Web 成为第二个当前消费方后不予采纳:重复的语法、排序、边界和失效处理会产生偏差,而且浏览器侧代码无法安全访问宿主工作区。
+
+**通过普通文件系统工具 RPC 扫描文件。** 不予采纳,因为递归模糊发现属于编辑器低延迟工作,而不是面向模型的精确文件系统操作;该方案还会把菜单与工具策略及提供方往返绑定。
+
+**选择文件时立即附加其内容。** 不予采纳,因为该方案会在尚未确定相关性时消耗上下文,并绕过可从日志重建、可审计的 `read` 调用/结果序列。
+
+**用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。
+
+**RPC 完成前清空输入框。** 不予采纳,因为准备失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。
+
+## 验证
+
+包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。
+
+## 后果
+
+Web 与 TUI 现在共享 `@file` 发现语义和同一套结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包和一个宿主 RPC 领域,但浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。

+ 14 - 3
apps/cli/cordis.yml

@@ -89,6 +89,17 @@
   config:
     root: './.sessions'
 
+- id: session-query-sqlite
+  name: '@deepseek-ai/dsh-session-query-sqlite'
+  config:
+    path: './.sessions/session-query.db'
+
+- id: session-reference
+  name: '@deepseek-ai/dsh-session-reference'
+
+- id: file-reference-local
+  name: '@deepseek-ai/dsh-file-reference-local'
+
 - id: storage
   name: '@deepseek-ai/dsh-storage'
 
@@ -287,7 +298,7 @@
   name: '@deepseek-ai/dsh-client-ui-workspace'
 
 # Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over
-# it (ui-command), and the two reference sources (ui-skill / ui-subagent).
+# it (ui-command), and the two reference sources (ui-skill / ui-reference).
 - id: ui-slash
   name: '@deepseek-ai/dsh-client-ui-slash'
 
@@ -297,8 +308,8 @@
 - id: ui-skill
   name: '@deepseek-ai/dsh-client-ui-skill'
 
-- id: ui-subagent
-  name: '@deepseek-ai/dsh-client-ui-subagent'
+- id: ui-reference
+  name: '@deepseek-ai/dsh-client-ui-reference'
 
 - id: ui-question
   name: '@deepseek-ai/dsh-client-ui-question'

+ 5 - 1
apps/cli/package.json

@@ -36,7 +36,7 @@
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-skill": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slash": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-reference": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
     "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
@@ -46,6 +46,8 @@
     "@deepseek-ai/dsh-frontend": "workspace:^",
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-fs-policy": "workspace:^",
+    "@deepseek-ai/dsh-file-reference": "workspace:^",
+    "@deepseek-ai/dsh-file-reference-local": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
@@ -55,6 +57,8 @@
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
+    "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",

+ 69 - 9
apps/web/tests/slash-flow.snapshot.ts

@@ -2,13 +2,13 @@
 // Assembled keyless snapshot of the slash/input/session convergence under the
 // agent-parity model: the New Session view state locks the composer until a
 // Workspace is picked (connectWorkspace materializes the full Session+Agent),
-// the '/' menu serves the session's wire command catalog (sessions are always
-// agent-backed — no draft/materialized split), a leadingInput command claims,
-// submits over the wire, and notices its result, and the SAME composer
-// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
-// flips blank and surfaces the session in lists. This is the user-visible
-// acceptance anchor — package mocks do not substitute for the assembled
-// application transcript.
+// the '@' menu descends a Host-backed file directory, the '/' menu serves the
+// session's wire command catalog (sessions are always agent-backed — no
+// draft/materialized split), a leadingInput command claims, submits over the
+// wire, and notices its result, and the SAME composer textarea then carries
+// the first plain send, whose ACCEPTANCE (not attempt) flips blank and
+// surfaces the session in lists. This is the user-visible acceptance anchor —
+// package mocks do not substitute for the assembled application transcript.
 import { readFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -27,7 +27,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
   { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
   { id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
-  { id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
+  { id: '@deepseek-ai/dsh-client-ui-reference', dir: 'ui-reference', url: '/plugins/ui-reference.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-slash'] },
   {
     id: '@deepseek-ai/dsh-client-ui-workspace',
     dir: 'ui-workspace',
@@ -114,7 +114,7 @@ async function typeComposer(composer: HTMLTextAreaElement, value: string): Promi
   await waitFor(() => { expect(composer.value).toBe(value) })
 }
 
-it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
+it('locked view state, connectWorkspace unlock, @file and /echo chains, and blank-on-acceptance ride one resident composer', async () => {
   boot('?fixture=empty')
 
   // View state: no session entity — the composer renders locked; only the
@@ -141,6 +141,23 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
   )
   expect(composer.disabled).toBe(false)
 
+  // '@' combines Host-backed references. Picking a directory keeps
+  // completion open at its trailing slash; picking a file closes it with a
+  // separator so ordinary prompt text can continue.
+  await typeComposer(composer, '@')
+  const referenceMenu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
+  await waitFor(() => { expect(visibleText(referenceMenu)).toContain('Folder · notes/') })
+  const referenceSections = [
+    within(referenceMenu).getByText('文件与文件夹').textContent,
+  ]
+  fireEvent.mouseDown(screen.getByRole('option', { name: /Folder · notes\// }))
+  await waitFor(() => { expect(composer.value).toBe('@notes/') })
+  const nestedFile = await screen.findByRole('option', { name: /File · demo\.txt/ })
+  fireEvent.mouseDown(nestedFile)
+  await waitFor(() => { expect(composer.value).toBe('@notes/demo.txt ') })
+  const filePathCompleted = composer.value
+  await typeComposer(composer, '')
+
   // '/' opens the menu with the session's wire command catalog (the session
   // is agent-backed from birth — the catalog is the single-address list).
   await typeComposer(composer, '/')
@@ -178,14 +195,57 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
   expect({
     menuHadEcho: menuText.includes('echo'),
     menuHadCompact: menuText.includes('compact'),
+    referenceSections,
+    filePathCompleted,
     composerSurvivedConversion: after === before,
     sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
   }).toMatchInlineSnapshot(`
     {
       "composerSurvivedConversion": true,
+      "filePathCompleted": "@notes/demo.txt ",
       "menuHadCompact": true,
       "menuHadEcho": true,
+      "referenceSections": [
+        "文件与文件夹",
+      ],
       "sessionListed": "nova1 session",
     }
   `)
 })
+
+it('the assembled @ menu inserts a session candidate as one atomic chip', async () => {
+  boot('?fixture')
+  const composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
+    'Describe what you want to build', {}, { timeout: 10_000 },
+  )
+  await typeComposer(composer, '@')
+  const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
+  await waitFor(() => {
+    expect(visibleText(menu)).toContain('Session · Fixture child session')
+  })
+  const referenceSections = [
+    within(menu).getByText('文件与文件夹').textContent,
+    within(menu).getByText('Session 对话').textContent,
+  ]
+  fireEvent.mouseDown(screen.getByRole('option', { name: /Session · Fixture child session/ }))
+  await waitFor(() => {
+    expect(composer.value).toBe('\uFFFC')
+  })
+  const chip = document.querySelector<HTMLElement>('[data-decoration="chip"]')
+  expect({
+    atomicDraftLength: composer.value.length,
+    chipLabel: chip?.title,
+    menuClosed: screen.queryByRole('listbox', { name: 'Trigger suggestions' }) === null,
+    referenceSections,
+  }).toMatchInlineSnapshot(`
+    {
+      "atomicDraftLength": 1,
+      "chipLabel": "@Fixture child session",
+      "menuClosed": true,
+      "referenceSections": [
+        "文件与文件夹",
+        "Session 对话",
+      ],
+    }
+  `)
+})

+ 9 - 1
docs/capability-seams.md

@@ -47,6 +47,9 @@ flowchart LR
   svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
   pkg_session_reference["session-reference"]
   pkg_tool_session_query["tool-session-query"]
+  pkg_file_reference["file-reference"]
+  svc_fileReferences["ctx.fileReferences<br/>Workspace file-reference discovery"]
+  pkg_file_reference_local["file-reference-local"]
   svc_sessionReferences["ctx.sessionReferences<br/>Cross-session snapshot preparation"]
   pkg_tui["tui"]
   pkg_session_title["session-title"]
@@ -152,6 +155,8 @@ flowchart LR
   pkg_compact --> svc_compact
   pkg_compact_basic --> svc_compact
   pkg_compact_tool_result_prune --> svc_toolResultPrune
+  pkg_file_reference --> svc_fileReferences
+  pkg_file_reference_local --> svc_fileReferences
   pkg_fs --> svc_fs
   pkg_fs_local --> svc_fs
   pkg_fs_sandbox --> svc_fs
@@ -224,6 +229,7 @@ flowchart LR
   svc_codeRuntime --> pkg_tools
   svc_commands --> pkg_tui
   svc_compact --> pkg_compact_basic
+  svc_fileReferences --> pkg_apiproxy
   svc_fs --> pkg_tool_fs
   svc_httpServer --> pkg_connection
   svc_httpServer --> pkg_hmr
@@ -248,6 +254,7 @@ flowchart LR
   svc_sessionPersistence --> pkg_tool_bash
   svc_sessionQuery --> pkg_session_reference
   svc_sessionQuery --> pkg_tool_session_query
+  svc_sessionReferences --> pkg_apiproxy
   svc_sessionReferences --> pkg_tui
   svc_sessions --> pkg_agent
   svc_sessions --> pkg_agent_loop
@@ -305,7 +312,8 @@ flowchart LR
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
 | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
 | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
-| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
+| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | The local provider owns one invalidated path index per agent; Host RPC projects its cancellable path candidates to browser reference sources. |
+| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | `apiproxy`, [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; Host and TUI adapters own mention syntax. |
 | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
 | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
 | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |

+ 21 - 2
docs/config-catalog.md

@@ -380,6 +380,24 @@ export interface ToolResultPruneConfig {
 
 Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts)
 
+## `@deepseek-ai/dsh-file-reference-local`
+
+Requires: `agents`
+
+```ts config-catalog
+/** Local file-reference discovery configuration. */
+export interface Config {
+  /** Maximum ranked candidates returned for one query. */
+  maxResults?: number
+  /** Maximum indexed files and directories per agent workspace. */
+  maxEntries?: number
+  /** Directory basenames never traversed or offered. */
+  excludedDirectories?: string[]
+}
+```
+
+Source: [`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts)
+
 ## `@deepseek-ai/dsh-fs-local`
 
 ```ts config-catalog
@@ -1781,7 +1799,7 @@ export interface TuiConfig {
 }
 ```
 
-Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:269`](../packages/ui/tui/src/index.ts)
 
 ## `@deepseek-ai/dsh-tui-demo`
 
@@ -2052,12 +2070,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts))
@@ -2086,6 +2104,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
 - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts))
 - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
 - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
+- `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts))
 - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
 - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
 - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))

+ 8 - 8
docs/cordis-catalog/events.md

@@ -641,7 +641,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts)
 
 ### `session/disposed` — emit
 
@@ -662,7 +662,7 @@ Emitted once when an announced session leaves the store, including publication r
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts)
 
 ### `session/event` — emit
 
@@ -685,7 +685,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts)
 
 ### `session/flush` — parallel
 
@@ -706,7 +706,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
 
 Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts)
 
 ## `slash/*`
 
@@ -726,7 +726,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco
 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:228`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-consume-token` — bail
 
@@ -742,7 +742,7 @@ Consumes one command token after business success (popup settle / menu-pick exec
 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-insert-reference` — bail
 
@@ -758,7 +758,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr
 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:235`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-insert-text` — bail
 
@@ -775,7 +775,7 @@ Replaces the trigger token span with literal text — the plain-text reference p
 'slash/input-insert-text'(request: InsertTextRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:250`](../../packages/client/ui-slash/src/types.ts)
 
 ## `subagent/*`
 

+ 20 - 1
docs/cordis-catalog/services.md

@@ -469,6 +469,25 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT
 
 Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
 
+## `ctx.fileReferences` — `FileReferenceService` (abstract seam)
+
+Host capability for cancellable file-reference discovery.
+
+```ts cordis-catalog
+/**
+ * List file and directory candidates for one agent's working directory.
+ * @param agent - target agent whose session cwd bounds discovery.
+ * @param query - path text following `@` or `@"`.
+ * @param signal - caller cancellation.
+ * @returns deterministic path-only candidates.
+ */
+abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise<FileReferenceCandidate[]>
+```
+
+Types: [Agent](../core-data-structures/core.md)
+
+Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts)
+
 ## `ctx.fs` — `FileSystem` (abstract seam)
 
 Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
@@ -1342,7 +1361,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:598`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 

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

@@ -9,8 +9,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
 | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`file-reference-local`](../packages/context/file-reference-local), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` |
 | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` |
@@ -34,14 +34,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:228`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:235`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:250`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../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:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 45 - 31
docs/module-graph.md

@@ -147,13 +147,13 @@ flowchart TD
     pkg_client_ui_models["client-ui-models"]
     pkg_client_ui_primitives["client-ui-primitives"]
     pkg_client_ui_question["client-ui-question"]
+    pkg_client_ui_reference["client-ui-reference"]
     pkg_client_ui_settings["client-ui-settings"]
     pkg_client_ui_settings_general["client-ui-settings-general"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_slash["client-ui-slash"]
     pkg_client_ui_slots["client-ui-slots"]
-    pkg_client_ui_subagent["client-ui-subagent"]
     pkg_client_ui_theme["client-ui-theme"]
     pkg_client_ui_trajectory["client-ui-trajectory"]
     pkg_client_ui_workspace["client-ui-workspace"]
@@ -165,6 +165,8 @@ flowchart TD
     pkg_code_runtime_worker["code-runtime-worker"]
   end
   subgraph group_context["packages/context"]
+    pkg_file_reference["file-reference"]
+    pkg_file_reference_local["file-reference-local"]
     pkg_session_reference["session-reference"]
     pkg_time_context["time-context"]
     pkg_workspace_context["workspace-context"]
@@ -271,9 +273,6 @@ flowchart TD
   pkg_client_ui_sidebar --> pkg_client_ui_primitives
   pkg_client_ui_sidebar --> pkg_client_ui_slots
   pkg_client_ui_sidebar --> pkg_invariants
-  pkg_client_ui_slash --> pkg_client_runtime
-  pkg_client_ui_slash --> pkg_client_ui_slots
-  pkg_client_ui_slash --> pkg_invariants
   pkg_client_ui_workspace --> pkg_client_runtime
   pkg_client_ui_workspace --> pkg_client_ui_primitives
   pkg_client_ui_workspace --> pkg_client_ui_slots
@@ -304,26 +303,12 @@ flowchart TD
   pkg_system_prompt --> pkg_scope
   pkg_web --> pkg_invariants
   pkg_web --> pkg_llm
-  pkg_client_ui_conversation --> pkg_client_runtime
-  pkg_client_ui_conversation --> pkg_client_ui_primitives
-  pkg_client_ui_conversation --> pkg_client_ui_slash
-  pkg_client_ui_conversation --> pkg_client_ui_slots
-  pkg_client_ui_conversation --> pkg_invariants
   pkg_client_ui_settings_general --> pkg_client_locale
   pkg_client_ui_settings_general --> pkg_client_runtime
   pkg_client_ui_settings_general --> pkg_client_ui_primitives
   pkg_client_ui_settings_general --> pkg_client_ui_settings
   pkg_client_ui_settings_general --> pkg_client_ui_slots
   pkg_client_ui_settings_general --> pkg_invariants
-  pkg_client_ui_skill --> pkg_client_connection
-  pkg_client_ui_skill --> pkg_client_runtime
-  pkg_client_ui_skill --> pkg_client_ui_slash
-  pkg_client_ui_skill --> pkg_client_ui_slots
-  pkg_client_ui_skill --> pkg_invariants
-  pkg_client_ui_subagent --> pkg_client_runtime
-  pkg_client_ui_subagent --> pkg_client_ui_slash
-  pkg_client_ui_subagent --> pkg_client_ui_slots
-  pkg_client_ui_subagent --> pkg_invariants
   pkg_client_ui_theme --> pkg_client_locale
   pkg_client_ui_theme --> pkg_client_runtime
   pkg_client_ui_theme --> pkg_client_ui_primitives
@@ -381,13 +366,6 @@ flowchart TD
   pkg_app_boot --> pkg_invariants
   pkg_app_boot --> pkg_paths
   pkg_app_boot --> pkg_system_prompt
-  pkg_client_ui_command --> pkg_client_connection
-  pkg_client_ui_command --> pkg_client_runtime
-  pkg_client_ui_command --> pkg_client_ui_conversation
-  pkg_client_ui_command --> pkg_client_ui_primitives
-  pkg_client_ui_command --> pkg_client_ui_slash
-  pkg_client_ui_command --> pkg_client_ui_slots
-  pkg_client_ui_command --> pkg_invariants
   pkg_client_ui_layout --> pkg_client_runtime
   pkg_client_ui_layout --> pkg_client_ui_slots
   pkg_client_ui_layout --> pkg_client_ui_theme
@@ -470,6 +448,8 @@ flowchart TD
   pkg_user_interaction --> pkg_agent
   pkg_user_interaction --> pkg_invariants
   pkg_user_interaction --> pkg_llm
+  pkg_file_reference --> pkg_agent
+  pkg_file_reference --> pkg_invariants
   pkg_time_context --> pkg_agent
   pkg_time_context --> pkg_invariants
   pkg_time_context --> pkg_session
@@ -543,6 +523,10 @@ flowchart TD
   pkg_permission --> pkg_sandbox_policy
   pkg_permission --> pkg_session
   pkg_permission --> pkg_user_approval
+  pkg_client_ui_slash --> pkg_client_runtime
+  pkg_client_ui_slash --> pkg_client_ui_slots
+  pkg_client_ui_slash --> pkg_file_reference
+  pkg_client_ui_slash --> pkg_invariants
   pkg_session_reference --> pkg_agent
   pkg_session_reference --> pkg_compact
   pkg_session_reference --> pkg_invariants
@@ -675,6 +659,26 @@ flowchart TD
   pkg_tool_ask_user --> pkg_invariants
   pkg_tool_ask_user --> pkg_tools
   pkg_tool_ask_user --> pkg_user_interaction
+  pkg_client_ui_conversation --> pkg_client_runtime
+  pkg_client_ui_conversation --> pkg_client_ui_primitives
+  pkg_client_ui_conversation --> pkg_client_ui_slash
+  pkg_client_ui_conversation --> pkg_client_ui_slots
+  pkg_client_ui_conversation --> pkg_invariants
+  pkg_client_ui_reference --> pkg_client_connection
+  pkg_client_ui_reference --> pkg_client_runtime
+  pkg_client_ui_reference --> pkg_client_ui_slash
+  pkg_client_ui_reference --> pkg_file_reference
+  pkg_client_ui_reference --> pkg_invariants
+  pkg_client_ui_skill --> pkg_client_connection
+  pkg_client_ui_skill --> pkg_client_runtime
+  pkg_client_ui_skill --> pkg_client_ui_slash
+  pkg_client_ui_skill --> pkg_client_ui_slots
+  pkg_client_ui_skill --> pkg_invariants
+  pkg_file_reference_local --> pkg_agent
+  pkg_file_reference_local --> pkg_file_reference
+  pkg_file_reference_local --> pkg_invariants
+  pkg_file_reference_local --> pkg_system_prompt
+  pkg_file_reference_local --> pkg_tools
   pkg_workspace_context --> pkg_agent
   pkg_workspace_context --> pkg_fs
   pkg_workspace_context --> pkg_invariants
@@ -751,6 +755,7 @@ flowchart TD
   pkg_tui --> pkg_agent
   pkg_tui --> pkg_agent_loop
   pkg_tui --> pkg_commands
+  pkg_tui --> pkg_file_reference_local
   pkg_tui --> pkg_goal
   pkg_tui --> pkg_invariants
   pkg_tui --> pkg_llm
@@ -765,6 +770,13 @@ flowchart TD
   pkg_tui --> pkg_token_meter
   pkg_tui --> pkg_tools
   pkg_tui --> pkg_user_interaction
+  pkg_client_ui_command --> pkg_client_connection
+  pkg_client_ui_command --> pkg_client_runtime
+  pkg_client_ui_command --> pkg_client_ui_conversation
+  pkg_client_ui_command --> pkg_client_ui_primitives
+  pkg_client_ui_command --> pkg_client_ui_slash
+  pkg_client_ui_command --> pkg_client_ui_slots
+  pkg_client_ui_command --> pkg_invariants
   pkg_agent_spine_demo --> pkg_agent
   pkg_agent_spine_demo --> pkg_agent_loop
   pkg_agent_spine_demo --> pkg_goal
@@ -882,7 +894,6 @@ flowchart TD
 | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
 | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
@@ -894,10 +905,7 @@ flowchart TD
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
-| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
 | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
@@ -916,7 +924,6 @@ flowchart TD
 | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
-| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
 | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) |
@@ -938,6 +945,7 @@ flowchart TD
 | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) |
 | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
+| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) |
 | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
 | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
@@ -954,6 +962,7 @@ flowchart TD
 | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
 | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
+| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) |
 | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
@@ -975,6 +984,10 @@ flowchart TD
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants) |
+| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
 | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
@@ -987,7 +1000,8 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
 | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 1 - 0
packages/client/connection/src/client/api.ts

@@ -10,6 +10,7 @@ export type {
   ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
   WorkspaceApi, WorkspaceId, WorkspaceView,
   CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
+  ReferencesApi, FileReferenceItem, SessionReferenceItem,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
 export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
 export type {

+ 32 - 0
packages/client/connection/src/client/fixture.ts

@@ -798,6 +798,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         })
       },
     },
+    references: {
+      files: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const query = request.payload.query.toLocaleLowerCase()
+        const items = [
+          { path: 'notes', kind: 'directory' as const },
+          { path: 'README.md', kind: 'file' as const },
+          { path: 'notes/demo.txt', kind: 'file' as const },
+        ].filter(item => item.path.toLocaleLowerCase().includes(query))
+        return ok(request, { items })
+      },
+      sessions: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const query = request.payload.query.toLocaleLowerCase()
+        const items = sessions
+          .filter(item => item.sessionId !== request.payload.sessionId)
+          .filter(item => String(item.sessionId).toLocaleLowerCase().includes(query)
+            || item.cwd?.toLocaleLowerCase().includes(query) === true)
+          .map(item => ({
+            sessionId: item.sessionId,
+            label: item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId),
+            ...item.cwd === undefined ? {} : { cwd: item.cwd },
+            createdAt: item.updatedAt,
+            mention: `@[${item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId)}](dsh-session:${btoa(JSON.stringify(item.sessionId)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')})`,
+          }))
+        return ok(request, { items })
+      },
+    },
     events: {
       async *mux(_request, signal) {
         const conn = new FxInbox<MuxFrame>()
@@ -919,6 +949,8 @@ export class FixtureApiClient extends AbstractApiClient {
       // The in-memory execute never blocks, so a never-aborting signal is faithful here.
       case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
       case 'skill.list': return this.api.skills.list(request)
+      case 'reference.files': return this.api.references.files(request)
+      case 'reference.sessions': return this.api.references.sessions(request)
     }
   }
 

+ 1 - 0
packages/client/connection/src/client/index.ts

@@ -15,6 +15,7 @@ export type {
   ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
   ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
   CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
+  ReferencesApi, FileReferenceItem, SessionReferenceItem,
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
   ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
   IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

+ 5 - 0
packages/client/connection/tests/fake-api.ts

@@ -104,6 +104,11 @@ export class FakeApiClient implements IApiClient {
     list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
   }
 
+  readonly references: IApiClient['references'] = {
+    files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))),
+    sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))),
+  }
+
   /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
   suppressStreamOpen = false
 

+ 5 - 0
packages/client/runtime/src/client/sessions/conversation.ts

@@ -4,6 +4,7 @@
 // string here (narrow to real brands when convenient).
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { PromptPrefixContext } from '@deepseek-ai/dsh-session/types'
 import type {
   RpcError, SessionId, ToolCallView, ToolResultView,
 } from '@deepseek-ai/dsh-client-connection/client'
@@ -48,6 +49,8 @@ export interface UserMessageNode {
   time: number
   content: readonly ContentBlock[]
   source: unknown
+  /** Model-hidden descriptors for contexts baked ahead of this direct prompt. */
+  prefixContexts?: readonly PromptPrefixContext[]
 }
 
 /** A finalized (or interruption-frozen) assistant message. */
@@ -74,6 +77,8 @@ export interface SteeringMessageNode {
   turn: number
   content: readonly ContentBlock[]
   source: unknown
+  /** Model-hidden descriptors for contexts baked ahead of this direct prompt. */
+  prefixContexts?: readonly PromptPrefixContext[]
 }
 
 /** A context/system injection surfaced in the flow. */

+ 9 - 2
packages/client/runtime/src/client/sessions/fold-adapter.ts

@@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 // go through it — the package root points at lib/index.js (needs a build) which the vite
 // browser bundle cannot resolve; surface.ts has no Node dependencies.
 import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
+import { displayPromptContent } from '@deepseek-ai/dsh-session/display'
 import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
 import type { ConversationNode } from './conversation.ts'
 import { toAssistantBlocks } from './conversation.ts'
@@ -51,7 +52,10 @@ function materializeNode(
       }
       return {
         kind: 'user', seq: event.seq, time: event.time,
-        content: event.data.content, source: event.data.source,
+        content: displayPromptContent(event.data), source: event.data.source,
+        ...event.data.envelope === undefined
+          ? {}
+          : { prefixContexts: event.data.envelope.prefixContexts },
       }
     case 'assistant/message':
       return {
@@ -62,7 +66,10 @@ function materializeNode(
     case 'steering/message':
       return {
         kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
-        content: event.data.content, source: event.data.source,
+        content: displayPromptContent(event.data), source: event.data.source,
+        ...event.data.envelope === undefined
+          ? {}
+          : { prefixContexts: event.data.envelope.prefixContexts },
       }
     case 'tool/result': {
       const call = callIndex.get(String(event.data.callId))

+ 7 - 2
packages/client/runtime/src/client/sessions/session.ts

@@ -173,9 +173,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
    * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
    * @param content - core content blocks verbatim.
    * @param mode - queue appends after the current turn; steer interrupts it.
+   * @param signal - optional cancellation for Host-side pre-enqueue preparation.
    * @returns the prompt result (also mirrored into promptError on failure).
    */
-  async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
+  async prompt(
+    content: ContentBlock[],
+    mode: 'queue' | 'steer',
+    signal?: AbortSignal,
+  ): Promise<RpcResult<{ accepted: true }>> {
     this.promptError = null
     this.lastAgentError = null
     // Synchronous, before the first await: the blank → engaging edge must be
@@ -185,7 +190,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     this.notifier.markDirty()
     let result: RpcResult<{ accepted: true }>
     try {
-      result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
+      result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content }, signal)).result
     } catch (error) {
       result = transportError(error)
     }

+ 5 - 0
packages/client/runtime/tests/fake-api.ts

@@ -126,6 +126,11 @@ export class FakeApiClient implements IApiClient {
     list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
   }
 
+  readonly references: IApiClient['references'] = {
+    files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))),
+    sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))),
+  }
+
   /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
   suppressStreamOpen = false
 

+ 33 - 0
packages/client/runtime/tests/fold-adapter.spec.ts

@@ -55,6 +55,39 @@ describe('FoldAdapter', () => {
     expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
   })
 
+  it('replays only the direct prompt while retaining referenced-session descriptors', () => {
+    const adapter = new FoldAdapter()
+    const prefixContexts = [{
+      source: { kind: 'plugin', plugin: 'session-reference' },
+      meta: {
+        kind: 'session-reference',
+        version: 1,
+        references: [{ sessionId: 'source', label: 'Research' }],
+      },
+    }]
+    adapter.reset([at(0, {
+      type: 'user/message',
+      surfaceOp: 'append',
+      data: {
+        content: [
+          { type: 'text', text: 'snapshot' },
+          { type: 'text', text: '\n\n## My request:\n' },
+          { type: 'text', text: 'compare @Research' },
+        ],
+        source: { kind: 'user' },
+        envelope: {
+          displayContent: [{ type: 'text', text: 'compare @Research' }],
+          prefixContexts,
+        },
+      },
+    })], 0)
+    expect(adapter.nodes().nodes[0]).toMatchObject({
+      kind: 'user',
+      content: [{ type: 'text', text: 'compare @Research' }],
+      prefixContexts,
+    })
+  })
+
   it('returns call:null for a tool-result whose call fell outside the window', () => {
     const adapter = new FoldAdapter()
     adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)

+ 7 - 1
packages/client/runtime/tests/session.spec.ts

@@ -219,17 +219,23 @@ describe('paging', () => {
 describe('prompt and cancel errors', () => {
   it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
     const { api, session } = makeSession()
+    const prompt = vi.spyOn(api.sessions, 'prompt')
+    const controller = new AbortController()
     // The blank → engaging edge fires before the RPC settles: the first-send
     // flow reads the phase on the session area's first frame to keep the
     // guidance hero from flashing back in.
     expect(session.getSnapshot().composerPhase).toBe('blank')
-    const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
+    const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue', controller.signal)
     expect(session.getSnapshot().composerPhase).toBe('engaging')
     const result = await inFlight
     expect(result.ok).toBe(true)
     // Monotone: settlement alone does not step the phase anywhere.
     expect(session.getSnapshot().composerPhase).toBe('engaging')
     expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
+    expect(prompt).toHaveBeenCalledWith(
+      { sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] },
+      controller.signal,
+    )
     // First content lands (running turn): engaging → active.
     session.handleRunning(true)
     expect(session.getSnapshot().composerPhase).toBe('active')

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

@@ -27,7 +27,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
+export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/
 
 /**
  * Documented TEMPORARY exemption, not a platform module (hence not in

+ 3 - 3
packages/client/ui-conversation/README.i18n.yaml

@@ -1,6 +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
-README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467
-README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311
+#   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
+README.md: 8c807d8ab9c9fa4559c846876a99c373366369de
+README.zh.md: 13ee84a45ecbba3c950beb4478ecb80b15b47032

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

@@ -14,6 +14,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
 
 Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
 
+Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay uses the session package's display projection for prompt envelopes; session-reference metadata projects its confirmed label as a reference chip even when following prompt text is adjacent, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON.
+
 `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
 
 ## Model Experience

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

@@ -14,6 +14,8 @@
 
 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
 
+普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放对提示词封套使用会话包的显示投影;会话引用元数据会把已确认的标签投影为引用 chip,即使后续提示词文本与标签直接相邻也如此,并在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。
+
 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
 
 ## 模型体验

+ 17 - 3
packages/client/ui-conversation/src/client/chat/MessageItem.module.css

@@ -7,9 +7,17 @@
   justify-content: flex-end;
 }
 
+.userStack {
+  display: flex;
+  max-width: min(525px, 82%);
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 6px;
+}
+
 .bubble {
   /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
-  max-width: min(525px, 82%);
+  max-width: 100%;
   background: var(--dsw-specific-bubble);
   border-radius: 22px;
   /* 44px single-line bubble: 24 line + 10 vertical padding each side. */
@@ -19,6 +27,12 @@
   color: var(--dsw-alias-label-primary);
 }
 
+.referenceSummary {
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  line-height: 18px;
+}
+
 .badge {
   display: inline-block;
   margin-bottom: 4px;
@@ -33,8 +47,8 @@
   padding: 2px 0;
 }
 
-/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
-   spans render as chips; free geometry — no textarea pairing here). */
+/* Reference-chip projection inside a user bubble; free geometry means the
+   textarea overlay's metric pairing does not apply here. */
 .refChip {
   display: inline-block;
   margin: 0 2px;

+ 44 - 15
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -27,17 +27,21 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
 }
 
 /**
- * Display projection of reference forms in a user bubble (free geometry — no
- * textarea alignment constraint here); everything else stays plain text. The
- * logged model text remains the single truth; this is presentation only. Two
- * shapes decorate: legacy `<skill>name</skill>` spans (pre-decision-21
- * history) and plain-text `/name` / `@name` word-boundary tokens (decision
- * 21: the sent text IS the reference — the bubble uses the same plainest
- * token scan as the composer, minus the lexicon: sent tokens were validated
- * at compose time, so shape alone decorates).
+ * Decorate legacy skill spans, boundary-delimited plain references, and exact
+ * metadata-confirmed session labels in the user bubble. Confirmed labels may
+ * touch following prompt text because their durable metadata disambiguates
+ * the reference boundary. Logged message text remains unchanged.
  */
-function projectUserText(text: string): ReactNode {
-  const re = /<skill>([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g
+function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode {
+  const exactSessions = [...new Set(sessionLabels)]
+    .filter(label => label.length > 0)
+    .sort((left, right) => right.length - left.length)
+    .map(label => label.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
+  const sessionPattern = exactSessions.length === 0 ? '' : `@(?:${exactSessions.join('|')})|`
+  const re = new RegExp(
+    `<skill>([^<]+)</skill>|(^|\\s)(${sessionPattern}[/@][\\w-]+(?=\\s|$))`,
+    'gu',
+  )
   const parts: ReactNode[] = []
   let cursor = 0
   let m: RegExpExecArray | null
@@ -47,7 +51,7 @@ function projectUserText(text: string): ReactNode {
     const label = legacy ? `/${m[1]}` : m[3] ?? ''
     if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
     parts.push(
-      <span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
+      <span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'reference' : 'skill'}>
         {label}
       </span>,
     )
@@ -58,17 +62,42 @@ function projectUserText(text: string): ReactNode {
   return <>{parts}</>
 }
 
+function referencedSessionLabels(
+  node: UserMessageNode | SteeringMessageNode,
+): string[] {
+  const labels: string[] = []
+  for (const context of node.prefixContexts ?? []) {
+    const meta = context.meta
+    if (typeof meta !== 'object' || meta === null || Array.isArray(meta)
+      || meta.kind !== 'session-reference' || !Array.isArray(meta.references)) continue
+    for (const reference of meta.references) {
+      if (typeof reference !== 'object' || reference === null || Array.isArray(reference)) continue
+      const label = typeof reference.label === 'string'
+        ? reference.label
+        : typeof reference.sessionId === 'string' ? reference.sessionId : undefined
+      if (label !== undefined) labels.push(label)
+    }
+  }
+  return labels
+}
+
 export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
   switch (node.kind) {
     case 'user':
     case 'steering': {
       const { text, rest } = contentText(node.content)
+      const referencedSessions = referencedSessionLabels(node)
       return (
         <div className={css.userRow}>
-          <div className={css.bubble}>
-            {node.kind === 'steering' && <span className={css.badge}>插话</span>}
-            {projectUserText(text)}
-            {rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
+          <div className={css.userStack}>
+            <div className={css.bubble}>
+              {node.kind === 'steering' && <span className={css.badge}>插话</span>}
+              {projectUserText(text, referencedSessions)}
+              {rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
+            </div>
+            {referencedSessions.length > 0
+              ? <div className={css.referenceSummary}>引用会话 · {referencedSessions.join(', ')}</div>
+              : null}
           </div>
         </div>
       )

+ 2 - 7
packages/client/ui-conversation/src/client/input/contract.ts

@@ -211,7 +211,7 @@ export interface InputState {
 export interface SubmitAttempt {
   readonly seq: number
   readonly signal: AbortSignal
-  /** Draft at enter time; rollback restores it only while the live draft still equals it. */
+  /** Draft at enter time; settlement clears it only after acceptance. */
   readonly draftSnapshot: string
 }
 
@@ -250,11 +250,6 @@ export type InputEvent =
   | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
   | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
   | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
-  /**
-   * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT —
-   * undo must not resurrect sent content (mirrors submit-settled's success arm).
-   */
-  | { readonly type: 'send-committed' }
   | { readonly type: 'release' }
 
 /**
@@ -265,5 +260,5 @@ export type InputEvent =
 export type InputEffect =
   | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
   | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
-  | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
+  | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: 'queue' | 'steer' }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

+ 38 - 16
packages/client/ui-conversation/src/client/input/facade.ts

@@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
-  ReferenceInsert, SlashController, TokenSpan,
+  ReferenceInsert, SlashController, SubmitOutcome, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-slash/client'
 import type {
   EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
@@ -39,7 +39,7 @@ export interface SessionInputDeps {
   /** Queue read face; overlaid onto InputState.queue (absent = empty). */
   queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
   /** The plain-message sink (send choreography / materialize fork — the hub owns it). */
-  defaultSink(text: string, mode: 'queue' | 'steer'): void
+  defaultSink(text: string, mode: 'queue' | 'steer', signal: AbortSignal): Promise<SubmitOutcome>
 }
 
 /** Guard tier from the machine phase. */
@@ -97,15 +97,6 @@ export class SessionInputShell implements SessionInput {
     this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
   }
 
-  /**
-   * Clear the draft as a successful-send commit: no undo unit is recorded and
-   * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
-   * (the command path gets the same discipline from submit-settled success).
-   */
-  commitSend(): void {
-    this.run(this.core.dispatch({ type: 'send-committed' }))
-  }
-
   /**
    * Insert a newline at the selection as one machine transaction (the
    * execCommand path is gone — a second undo history would fork).
@@ -336,7 +327,7 @@ export class SessionInputShell implements SessionInput {
         return
       }
       case 'default-sink': {
-        this.sinkSerialized(fx.draft, fx.mode)
+        this.sinkSerialized(fx.attempt, fx.draft, fx.mode)
         return
       }
       default:
@@ -351,10 +342,10 @@ export class SessionInputShell implements SessionInput {
    * send — notice + draft and chips retained, never a silent downgrade to
    * the clipboard text. Chip-free drafts skip the async detour.
    */
-  private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
+  private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: 'queue' | 'steer'): void {
     const occurrences = this.core.state.occurrences
     if (occurrences.length === 0) {
-      this.deps.defaultSink(draft.trim(), mode)
+      this.settleDefault(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal))
       return
     }
     const slash = this.deps.slash?.()
@@ -374,13 +365,44 @@ export class SessionInputShell implements SessionInput {
           cursor = part.offset + 1
         }
         out += draft.slice(cursor)
-        this.deps.defaultSink(out.trim(), mode)
+        this.settleDefault(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal))
       },
       (error: unknown) => {
         controller.abort()
         if (this.disposed) return
         const message = error instanceof Error ? error.message : String(error)
-        this.notify('error', message)
+        this.run(this.core.dispatch({
+          type: 'submit-settled',
+          attempt,
+          ok: false,
+          message,
+        }))
+      },
+    )
+  }
+
+  private settleDefault(
+    attempt: SubmitAttempt,
+    pending: Promise<SubmitOutcome>,
+  ): void {
+    pending.then(
+      (outcome) => {
+        if (this.dead(attempt)) return
+        this.run(this.core.dispatch({
+          type: 'submit-settled',
+          attempt,
+          ok: outcome.kind === 'success',
+          outcome,
+        }))
+      },
+      (error: unknown) => {
+        if (this.dead(attempt)) return
+        this.run(this.core.dispatch({
+          type: 'submit-settled',
+          attempt,
+          ok: false,
+          message: error instanceof Error ? error.message : String(error),
+        }))
       },
     )
   }

+ 23 - 21
packages/client/ui-conversation/src/client/input/hub.ts

@@ -9,7 +9,7 @@
  * real host entity, so the sink is one unconditional prompt path.
  */
 import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
+import type { SlashController, SlashServiceContract, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
 import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
 import { queueReadFaceOf } from '../queue/store.ts'
 import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
@@ -57,7 +57,7 @@ export class InputHub implements InputService {
       slash: () => this.controller(actx),
       popup: () => this.popup(actx),
       queue: queueReadFaceOf(session),
-      defaultSink: (text, mode) => { this.sink(session, text, mode) },
+      defaultSink: (text, mode, signal) => this.sink(session, text, mode, signal),
     })
     this.shells.set(id, shell)
     // The one teardown axis: listeners, shell, and map entries all ride the
@@ -70,8 +70,13 @@ export class InputHub implements InputService {
           shell.insertReference(req.reference, req.span) ? true : undefined),
         actx.on('slash/input-consume-token', req =>
           shell.consumeToken(req.guard) ? true : undefined),
-        actx.on('slash/input-insert-text', req =>
-          shell.insertText(req.text, req.span) ? true : undefined),
+        actx.on('slash/input-insert-text', (req) => {
+          if (!shell.insertText(req.text, req.span)) return undefined
+          if (req.continue === true) {
+            shell.track(shell.snapshot.draft, req.span.start + req.text.length)
+          }
+          return true
+        }),
       ]
       return () => {
         for (const off of offs) off()
@@ -108,24 +113,21 @@ export class InputHub implements InputService {
   }
 
   /**
-   * Default sink: optimistic clear + prompt. The session is always a real
-   * host entity (materialized when its workspace was picked), so there is
-   * exactly one path; a failed first prompt is an ordinary prompt failure
-   * (error strip via promptError, draft restored only while untouched).
+   * Default sink: submit through the real host session and report acceptance
+   * to the input transaction. The draft and its reference occurrences remain
+   * resident until this promise succeeds.
    */
-  private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
-    if (text === '') return
-    const shell = this.shells.get(session.sessionId)
-    // Commit, not an editable clear: undo must not resurrect sent content.
-    shell?.commitSend()
-    void session.prompt([{ type: 'text', text }], mode).then(
-      (result) => {
-        if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
-      },
-      () => {
-        if (shell?.snapshot.draft === '') shell.setDraft(text)
-      },
-    )
+  private async sink(
+    session: Session,
+    text: string,
+    mode: 'queue' | 'steer',
+    signal: AbortSignal,
+  ): Promise<SubmitOutcome> {
+    if (text === '') return { kind: 'error', text: 'prompt is empty' }
+    const result = await session.prompt([{ type: 'text', text }], mode, signal)
+    return result.ok
+      ? { kind: 'success' }
+      : { kind: 'error', text: result.error.message }
   }
 
   private controller(actx: ClientContext): SlashController | undefined {

+ 15 - 20
packages/client/ui-conversation/src/client/input/machine.ts

@@ -167,7 +167,6 @@ export class InputMachine {
       case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
       case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
       case 'submit-settled': return this.onSubmitSettled(ev)
-      case 'send-committed': return this.onSendCommitted()
       case 'release': return this.onRelease()
       default: return unreachable(ev)
     }
@@ -477,7 +476,9 @@ export class InputMachine {
       this.phase = 'adjudicating'
       return [{ type: 'adjudicate', attempt, draft: this.draft }]
     }
-    return [{ type: 'default-sink', draft: this.draft, mode }]
+    const attempt = this.beginAttempt(mode)
+    this.phase = 'submitting'
+    return [{ type: 'default-sink', attempt, draft: this.draft, mode }]
   }
 
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -495,11 +496,18 @@ export class InputMachine {
     }
     // 'handled' (source dealt internally), {insert} (no enter-time span
     // semantics), or a miss: all land plain; only the miss flows to the sink.
+    if (outcome === undefined) {
+      this.phase = 'submitting'
+      return [{
+        type: 'default-sink',
+        attempt,
+        draft: attempt.draftSnapshot,
+        mode: flight.mode,
+      }]
+    }
     this.inflight = undefined
     this.phase = 'plain'
-    return outcome === undefined
-      ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
-      : []
+    return []
   }
 
   private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] {
@@ -529,8 +537,8 @@ export class InputMachine {
         : []
     }
     const text = ev.message ?? ev.outcome?.text ?? 'command failed'
-    // Drift guard: keep the enter-time draft (same claim) only while the
-    // live draft still equals it; user input typed during flight wins.
+    // Keep the same command claim only while the live draft still equals the
+    // enter-time draft; user input typed during flight wins.
     // Claimed re-entry additionally requires the watch to hold — an
     // enter-path snapshot may carry leading whitespace the token never had.
     if (this.draft === flight.attempt.draftSnapshot
@@ -543,19 +551,6 @@ export class InputMachine {
     return [{ type: 'notice', level: 'error', text }]
   }
 
-  /** Ordinary send accepted: clear as a commit (no undo unit; sent content
-   *  must not be resurrectable — same discipline as submit-settled success). */
-  private onSendCommitted(): InputEffect[] {
-    this.claim = undefined
-    this.occurrences = []
-    this.adopt('')
-    this.log = []
-    this.redoStack = []
-    this.typingRun = undefined
-    this.paste = undefined
-    return []
-  }
-
   private onRelease(): InputEffect[] {
     if (this.inflight !== undefined) {
       this.inflight.controller.abort()

+ 15 - 8
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // apply inject factories exercised end to end against the terminal thin
 // shape: the conversation surface (views triple, send choreography incl.
-// optimistic clear + failure restore THROUGH the declared store actions,
+// accepted-settlement clear + failure retention THROUGH the declared store actions,
 // openDetails = select action + layout orchestration, sessions.open
 // navigation), and the closeDetails details surface. Complements
 // chat-apply.spec.tsx (registration)
@@ -185,7 +185,7 @@ describe('conversation slot inject surface', () => {
     expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
   })
 
-  it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
+  it('the provide-channel input face submits through the machine sink: trim, accepted clear, failure retention without clobber', async () => {
     const b = await bench()
     const { injected } = b.conversationSurface(ROOT)
     const { state, actions } = b.inputSurface(ROOT)
@@ -194,20 +194,27 @@ describe('conversation slot inject surface', () => {
     actions.submit('queue')
     expect(b.sessionFake.prompt).not.toHaveBeenCalled()
     expect(state.getSnapshot().draft).toBe('   ')
-    // Success: cleared and stays cleared.
+    // Success: retained while the host decides, then cleared on acceptance.
     actions.setDraft('hello')
     actions.submit('queue')
-    expect(state.getSnapshot().draft).toBe('')
-    await Promise.resolve()
-    expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
-    // Failure: restored (draft still empty when the rejection lands).
+    expect(state.getSnapshot().draft).toBe('hello')
+    await vi.waitFor(() => {
+      expect(state.getSnapshot().draft).toBe('')
+    })
+    expect(b.sessionFake.prompt).toHaveBeenCalledWith(
+      [{ type: 'text', text: 'hello' }],
+      'queue',
+      expect.any(AbortSignal),
+    )
+    // Failure: the original draft remains available for retry.
     b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
     actions.setDraft('retry me')
     actions.submit('queue')
+    expect(state.getSnapshot().draft).toBe('retry me')
     await vi.waitFor(() => {
       expect(state.getSnapshot().draft).toBe('retry me')
     })
-    // Failure landing after new typing: no clobber (restore fills empty only).
+    // Failure landing after new typing: no clobber.
     b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
     actions.submit('queue')
     actions.setDraft('typed during flight')

+ 46 - 0
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -18,6 +18,52 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
 afterEach(cleanup)
 
 describe('MessageItem arms', () => {
+  it('shows referenced-session labels below the direct user prompt', () => {
+    const view = render(
+      <MessageItem node={{
+        kind: 'user',
+        seq: 1,
+        source: { kind: 'user' },
+        content: [{ type: 'text', text: 'compare @Research notes' }],
+        prefixContexts: [{
+          source: { kind: 'plugin', plugin: 'session-reference' },
+          meta: {
+            kind: 'session-reference',
+            references: [
+              { sessionId: 'source', label: 'Research notes' },
+              { sessionId: 'fallback' },
+            ],
+          },
+        }],
+      } as never}
+      />,
+    )
+    expect(view.container.textContent).toContain('compare @Research notes')
+    expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@Research notes')
+    expect(view.getByText('引用会话 · Research notes, fallback')).toBeTruthy()
+  })
+
+  it('styles a referenced-session label when prompt text follows without whitespace', () => {
+    const view = render(
+      <MessageItem node={{
+        kind: 'user',
+        seq: 2,
+        source: { kind: 'user' },
+        content: [{ type: 'text', text: '@你好这个在讲啥' }],
+        prefixContexts: [{
+          source: { kind: 'plugin', plugin: 'session-reference' },
+          meta: {
+            kind: 'session-reference',
+            references: [{ sessionId: 'source', label: '你好' }],
+          },
+        }],
+      } as never}
+      />,
+    )
+    expect(view.container.textContent).toContain('@你好这个在讲啥')
+    expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@你好')
+  })
+
   it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
     const view = render(
       <MessageItem node={{

+ 4 - 4
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -47,7 +47,7 @@ interface BenchOptions {
 
 /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
 function bench(over?: BenchOptions) {
-  const sink = vi.fn()
+  const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
   const lex = over?.lexicon
   type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
   const shell = new SessionInputShell({
@@ -109,7 +109,7 @@ describe('Enter semantics', () => {
   it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
     const { textarea, sink } = bench({ draft: 'hello' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('hello', 'queue')
+    expect(sink).toHaveBeenCalledWith('hello', 'queue', expect.any(AbortSignal))
     fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
     expect(sink).toHaveBeenCalledTimes(1)
     const empty = bench({ draft: '   ' })
@@ -177,7 +177,7 @@ describe('running and lock semantics (queue cut 1)', () => {
     expect(textarea.disabled).toBe(false) // running no longer locks
     fireEvent.change(textarea, { target: { value: '排队消息2' } })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
+    expect(sink).toHaveBeenCalledWith('排队消息2', 'queue', expect.any(AbortSignal))
     expect(button.getAttribute('aria-label')).toBe('Stop generating')
     fireEvent.click(button)
     expect(stop).toHaveBeenCalledTimes(1)
@@ -193,7 +193,7 @@ describe('running and lock semantics (queue cut 1)', () => {
   it('idle primary sends and disables on empty draft', () => {
     const { button, sink } = bench({ draft: 'go' })
     fireEvent.click(button)
-    expect(sink).toHaveBeenCalledWith('go', 'queue')
+    expect(sink).toHaveBeenCalledWith('go', 'queue', expect.any(AbortSignal))
     const empty = bench()
     expect(empty.button.disabled).toBe(true)
   })

+ 12 - 8
packages/client/ui-conversation/tests/input-machine.spec.ts

@@ -72,9 +72,10 @@ describe('input-machine: plain × enter', () => {
   it('non-command text falls to the default sink with the given mode', () => {
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    expect(m.dispatch({ type: 'enter', mode: 'steer' }))
-      .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
-    expect(m.state.phase).toBe('plain')
+    const effect = effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink')
+    expect(effect).toMatchObject({ draft: 'hello world', mode: 'steer' })
+    expect(effect.attempt.draftSnapshot).toBe('hello world')
+    expect(m.state.phase).toBe('submitting')
   })
 
   it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
@@ -97,8 +98,8 @@ describe('input-machine: plain × enter', () => {
   it('a non-whitespace prefix before "/" is not leading — default sink', () => {
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' }))
-      .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
+    expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink'))
+      .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' })
   })
 })
 
@@ -127,9 +128,12 @@ describe('input-machine: adjudication outcomes', () => {
   it('undefined outcome falls back to the default sink preserving the enter mode', () => {
     const m = new InputMachine()
     const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
-    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
-      .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
-    expect(m.state.phase).toBe('plain')
+    expect(effectAt(
+      m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }),
+      0,
+      'default-sink',
+    )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' })
+    expect(m.state.phase).toBe('submitting')
   })
 
   it("'handled' lands plain with zero effects (popup shell path)", () => {

+ 8 - 5
packages/client/ui-conversation/tests/input-matrix.spec.tsx

@@ -50,7 +50,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
 }
 
 function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise<SubmitOutcome> }) {
-  const sink = vi.fn()
+  const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
   const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink })
   const wiring = shell
   const view = mountBar(shell, over)
@@ -71,13 +71,16 @@ function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: s
 }
 
 describe('matrix row: plain', () => {
-  it('enter falls to the default sink; no claim on the currency; edits free', () => {
+  it('enter falls to the default sink; no claim on the currency; edits free', async () => {
     const { textarea, shell, sink } = bench()
     fireEvent.change(textarea, { target: { value: '普通消息' } })
     expect(shell.snapshot.claim).toBeUndefined()
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
-    expect(shell.snapshot.phase).toBe('plain')
+    expect(sink).toHaveBeenCalledWith('普通消息', 'queue', expect.any(AbortSignal))
+    expect(shell.snapshot.phase).toBe('submitting')
+    await vi.waitFor(() => {
+      expect(shell.snapshot.phase).toBe('plain')
+    })
   })
 })
 
@@ -175,7 +178,7 @@ describe('matrix row: locked (session disabled)', () => {
     expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
     fireEvent.change(textarea, { target: { value: '排队' } })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('排队', 'queue')
+    expect(sink).toHaveBeenCalledWith('排队', 'queue', expect.any(AbortSignal))
   })
 })
 

+ 116 - 0
packages/client/ui-conversation/tests/input-reference-submit.spec.ts

@@ -0,0 +1,116 @@
+/**
+ * Reference-submit transaction coverage: chips serialize through their
+ * owner, stay resident through Host rejection, and clear only after an
+ * accepted prompt.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SlashController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
+import { SessionInputShell } from '../src/client/input/facade.ts'
+import { PLACEHOLDER } from '../src/client/input/machine.ts'
+
+const mention = '@[Research](dsh-session:InNvdXJjZSI)'
+
+function chip(shell: SessionInputShell): void {
+  shell.setDraft('@res')
+  const accepted = shell.insertReference({
+    source: 'reference',
+    ref: mention,
+    label: '@Research',
+    clipboardText: mention,
+  }, {
+    start: 0,
+    end: 4,
+    draftRev: shell.snapshot.draftRev,
+  })
+  expect(accepted).toBe(true)
+}
+
+describe('reference submission', () => {
+  it('retains the chip on Host failure and clears it only after a later accepted retry', async () => {
+    const serializeReference = vi.fn(() => Promise.resolve(mention))
+    const sink = vi.fn<(_text: string, _mode: 'queue' | 'steer') => Promise<SubmitOutcome>>()
+      .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' })
+      .mockResolvedValueOnce({ kind: 'success' })
+    const slash = {
+      serializeReference,
+      track: vi.fn(),
+    } as unknown as SlashController
+    const shell = new SessionInputShell({
+      actx: {} as ClientContext,
+      slash: () => slash,
+      defaultSink: sink,
+    })
+    chip(shell)
+    expect(shell.snapshot).toMatchObject({
+      draft: PLACEHOLDER,
+      occurrences: [{ source: 'reference', ref: mention, label: '@Research' }],
+    })
+
+    shell.submit('queue')
+    expect(shell.snapshot.phase).toBe('submitting')
+    await vi.waitFor(() => {
+      expect(shell.snapshot.phase).toBe('plain')
+    })
+    expect(sink).toHaveBeenNthCalledWith(1, mention, 'queue', expect.any(AbortSignal))
+    expect(shell.snapshot).toMatchObject({
+      draft: PLACEHOLDER,
+      occurrences: [{ source: 'reference', ref: mention, label: '@Research' }],
+    })
+    expect(shell.notices.getSnapshot()).toMatchObject({
+      level: 'error',
+      text: 'snapshot unavailable',
+    })
+
+    shell.submit('queue')
+    await vi.waitFor(() => {
+      expect(shell.snapshot.draft).toBe('')
+    })
+    expect(sink).toHaveBeenNthCalledWith(2, mention, 'queue', expect.any(AbortSignal))
+    expect(shell.snapshot.occurrences).toEqual([])
+    expect(serializeReference).toHaveBeenCalledTimes(2)
+  })
+
+  it('blocks submission and retains the chip when its owner cannot serialize it', async () => {
+    const sink = vi.fn()
+    const slash = {
+      serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
+      track: vi.fn(),
+    } as unknown as SlashController
+    const shell = new SessionInputShell({
+      actx: {} as ClientContext,
+      slash: () => slash,
+      defaultSink: sink,
+    })
+    chip(shell)
+    shell.submit()
+    await vi.waitFor(() => {
+      expect(shell.snapshot.phase).toBe('plain')
+    })
+    expect(sink).not.toHaveBeenCalled()
+    expect(shell.snapshot.draft).toBe(PLACEHOLDER)
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.notices.getSnapshot()).toMatchObject({
+      level: 'error',
+      text: 'reference codec unavailable',
+    })
+  })
+
+  it('aborts Host-side preparation when the input shell is disposed', () => {
+    let signal: AbortSignal | undefined
+    const shell = new SessionInputShell({
+      actx: {} as ClientContext,
+      defaultSink: (_text, _mode, received) => {
+        signal = received
+        return new Promise<SubmitOutcome>(() => {})
+      },
+    })
+    shell.setDraft('send this')
+    shell.submit()
+    expect(signal?.aborted).toBe(false)
+    shell.dispose()
+    expect(signal?.aborted).toBe(true)
+    expect(shell.snapshot.phase).toBe('plain')
+    expect(shell.snapshot.draft).toBe('send this')
+  })
+})

+ 7 - 3
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -102,7 +102,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
   register?.(slash)
   const actx = sessions.scope(sessionId)! as ClientContext
   const controller = slash.sessionOf(actx)
-  const sink = vi.fn()
+  const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
   const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
   // The hub's listener wiring, verbatim.
   actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined)
@@ -215,7 +215,9 @@ describe('scenario D: execute-kind /compact', () => {
     act(() => { b2.shell.setDraft('/compact 现在') })
     fireEvent.keyDown(b2.textarea, { key: 'Enter' })
     // execute with trailing → matchEnter answers undefined → default sink.
-    await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
+    await vi.waitFor(() => {
+      expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue', expect.any(AbortSignal))
+    })
     expect(b2.executed).toHaveLength(0)
   })
 })
@@ -240,7 +242,9 @@ describe('scenario I: unknown /xyz + enter', () => {
     const b = await bench()
     act(() => { b.shell.setDraft('/xyz 干点啥') })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
-    await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
+    await vi.waitFor(() => {
+      expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue', expect.any(AbortSignal))
+    })
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.execute).not.toHaveBeenCalled()
   })

+ 2 - 2
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -21,7 +21,7 @@ import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
 
 /** Machine-backed wiring over a sink spy. */
 function fakeWiring() {
-  const sink = vi.fn()
+  const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
   const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink })
   return { wiring: shell, sink, shell }
 }
@@ -152,7 +152,7 @@ describe('ConversationRoot resident composer', () => {
     fireEvent.change(box, { target: { value: 'ordinary revised' } })
     expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
     fireEvent.keyDown(box, { key: 'Enter' })
-    expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
+    expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue', expect.any(AbortSignal))
     fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
     expect(b.open).toHaveBeenCalledWith(sid('root'))
   })

+ 3 - 3
packages/client/ui-subagent/README.i18n.yaml → packages/client/ui-reference/README.i18n.yaml

@@ -1,6 +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
-README.md: 7a70add139eae7bc507469b4fe7170359efdec31
-README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a
+#   pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md
+README.md: e7b280c09cf33f9c0c38ebffe0c5e4a9d22062fc
+README.zh.md: 4d9d41d24a9a4a61859a7fcf8d19cdb8742c475e

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

@@ -0,0 +1,25 @@
+# `@deepseek-ai/dsh-client-ui-reference`
+
+English | [中文](README.zh.md)
+
+Unified Web `@file` and `@session` source. The browser starts `reference.files` and `reference.sessions` Host RPCs together for an unquoted token, keeps the TUI's file-before-session ordering and labels, renders the rows under the non-selectable `文件与文件夹` and `Session 对话` headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only.
+
+File picks insert the natural `@path` text used by the TUI. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted.
+
+Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send delegates the canonical mention to `session.prompt`, where Host-side session-reference preparation owns validation, snapshotting, and model context.
+
+The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect.
+
+## Model Experience
+
+Indirectly, through `@deepseek-ai/dsh-file-reference-local` for path guidance and `@deepseek-ai/dsh-session-reference` for prepared session snapshots.
+
+#### KV Cache effect
+
+Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference prefix attached to that message; earlier target history remains unchanged.
+
+## Known Limitations and Deferred Work
+
+- **Candidate failure is intentionally quiet** — one unavailable or failed reference RPC yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path.
+- **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem.
+- **Session search remains metadata-only** — discovery filters session id and cwd through `ctx.sessionReferences`; title and transcript full-text search are not available.

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

@@ -0,0 +1,25 @@
+# `@deepseek-ai/dsh-client-ui-reference`
+
+[English](README.md) | 中文
+
+统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `reference.files` 和 `reference.sessions` 宿主 RPC,沿用 TUI 中文件在会话之前的顺序和标签,把各行分别渲染在不可选择的 `文件与文件夹` 和 `Session 对话` 标题下,并让任一候选领域的失败独立降级。尚未闭合的 `@"…` token 只搜索文件。
+
+选择文件会插入 TUI 使用的自然 `@path` 文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。
+
+选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` 提及标记。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会把规范提及标记交给 `session.prompt`,由宿主侧的会话引用准备负责校验、生成快照和模型上下文。
+
+`/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。
+
+## 模型体验
+
+间接影响模型体验:路径指引由 `@deepseek-ai/dsh-file-reference-local` 提供,准备后的会话快照由 `@deepseek-ai/dsh-session-reference` 提供。
+
+#### KV 缓存影响
+
+浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。
+
+## 已知限制与暂缓事项
+
+- **候选失败有意保持静默**:引用 RPC 不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。
+- **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。
+- **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferences` 筛选 session id 和 cwd;无法对标题和 transcript(文本记录)进行全文搜索。

+ 7 - 4
packages/client/ui-subagent/package.json → packages/client/ui-reference/package.json

@@ -1,6 +1,6 @@
 {
-  "name": "@deepseek-ai/dsh-client-ui-subagent",
-  "description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references",
+  "name": "@deepseek-ai/dsh-client-ui-reference",
+  "description": "Unified Web @file and @session reference source",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -24,6 +24,7 @@
   },
   "dshClient": {
     "inject": [
+      "@deepseek-ai/dsh-client-connection",
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-slash"
     ],
@@ -35,17 +36,19 @@
   },
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
-    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-file-reference": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slash": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-file-reference": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   },
   "files": [

+ 115 - 0
packages/client/ui-reference/src/client/index.ts

@@ -0,0 +1,115 @@
+/**
+ * Unified Web `@` reference source. File and session discovery run through
+ * cancellable Host RPCs in parallel and retain the TUI's ordering and labels.
+ *
+ * @module @deepseek-ai/dsh-client-ui-reference/client
+ */
+import type { ConnectionHandle, FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-client-connection/client'
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
+import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar'
+
+const FILE_SECTION = '文件与文件夹'
+const SESSION_SECTION = 'Session 对话'
+
+/** Required services: the slash registry and Host connection. */
+export const inject = ['slash', 'connection']
+
+/**
+ * Register the combined `@file` / `@session` source.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  const references = (ctx.get('connection') as ConnectionHandle).api.references
+  const source: SlashSource = {
+    trigger: '@',
+    name: 'reference',
+    async candidates(session: ClientSessionContext, { query, quoted, signal }) {
+      const files = references.files({ sessionId: session.sessionId, query }, signal).then(
+        response => response.result.ok ? response.result.value.items : [],
+        () => [],
+      )
+      const sessions = quoted === true
+        ? Promise.resolve([] as SessionReferenceItem[])
+        : references.sessions({ sessionId: session.sessionId, query }, signal).then(
+          response => response.result.ok ? response.result.value.items : [],
+          () => [],
+        )
+      const [fileItems, sessionItems] = await Promise.all([files, sessions])
+      if (signal.aborted) return []
+      return [
+        ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true)),
+        ...sessionItems.map(sessionCandidate),
+      ]
+    },
+    onPick({ candidate }) {
+      const value = parseCandidate(candidate.value)
+      if (value?.kind === 'file') {
+        return {
+          text: value.mention + (value.fileKind === 'file' ? ' ' : ''),
+          ...value.fileKind === 'directory' ? { continue: true } : {},
+        }
+      }
+      if (value?.kind === 'session') {
+        return {
+          insert: {
+            source: 'reference',
+            ref: value.mention,
+            label: `@${value.label}`,
+            clipboardText: value.mention,
+          },
+        }
+      }
+      return undefined
+    },
+    codec: {
+      clipboardText: ref => ref,
+      serialize: ref => Promise.resolve(ref),
+    },
+  }
+  const slash = ctx.get('slash') as SlashServiceContract
+  ctx.effect(() => slash.registerSource(source), 'ui-reference: @ source')
+}
+
+type ReferenceCandidateValue =
+  | { kind: 'file'; fileKind: FileReferenceItem['kind']; mention: string }
+  | { kind: 'session'; label: string; mention: string }
+
+function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) {
+  const mention = formatFileMention(candidate, preserveQuote)
+  if (mention === undefined) return []
+  const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
+  const directory = candidate.kind === 'directory'
+  const value: ReferenceCandidateValue = {
+    kind: 'file',
+    fileKind: candidate.kind,
+    mention,
+  }
+  return [{
+    name: `${directory ? 'Folder' : 'File'} · ${name}${directory ? '/' : ''}`,
+    description: candidate.path,
+    section: FILE_SECTION,
+    value: JSON.stringify(value),
+  }]
+}
+
+function sessionCandidate(candidate: SessionReferenceItem) {
+  const location = candidate.cwd ?? '(no cwd)'
+  const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
+  const value: ReferenceCandidateValue = {
+    kind: 'session',
+    label: candidate.label,
+    mention: candidate.mention,
+  }
+  return {
+    name: `Session · ${candidate.label}`,
+    description,
+    section: SESSION_SECTION,
+    value: JSON.stringify(value),
+  }
+}
+
+function parseCandidate(value: string | undefined): ReferenceCandidateValue | undefined {
+  if (value === undefined) return undefined
+  return JSON.parse(value) as ReferenceCandidateValue
+}

+ 1 - 1
packages/client/ui-subagent/src/index.ts → packages/client/ui-reference/src/index.ts

@@ -1,5 +1,5 @@
 /**
- * Subagent reference plugin, node half. Pure UI plugin: the empty apply
+ * File/session reference plugin, node half. Pure UI plugin: the empty apply
  * exists so the plugin appears in the host cordis.yml / Loader; the browser
  * half ships via exports["./client"], discovered through the package.json
  * dshClient declaration.

+ 4 - 4
packages/client/ui-subagent/src/invariant.ts → packages/client/ui-reference/src/invariant.ts

@@ -1,16 +1,16 @@
 /**
- * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-subagent`.
- * @module @deepseek-ai/dsh-client-ui-subagent/invariant
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-reference`.
+ * @module @deepseek-ai/dsh-client-ui-reference/invariant
  */
 
 /* jscpd:ignore-start */
 import type { Context } from 'cordis'
 import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
 
-const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-subagent'
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-reference'
 
 /** Cordis companion plugin name. */
-export const name = 'client-ui-subagent-invariant'
+export const name = 'client-ui-reference-invariant'
 /** Service required before the companion can reserve package ownership. */
 export const inject = ['invariants']
 

+ 325 - 0
packages/client/ui-reference/tests/browser-plugin.spec.ts

@@ -0,0 +1,325 @@
+/**
+ * Web reference source coverage: Host-backed file/session discovery, TUI
+ * ordering and labels, quoted-path suppression, pick projections, codec
+ * round-trip, and registration lifecycle.
+ */
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import type { FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  CandidateRequest, ClientSessionContext, SlashCandidate, SlashSource,
+} from '@deepseek-ai/dsh-client-ui-slash/client'
+import { apply, inject } from '../src/client/index.ts'
+
+const sid = (value: string): SessionId => value as SessionId
+const session: ClientSessionContext = { sessionId: sid('target') }
+
+type ReferenceResponse<T> =
+  | { result: { ok: true; value: { items: T[] } } }
+  | { result: { ok: false; error: { code: string; message: string } } }
+
+type ReferenceLookup<T> = (
+  payload: unknown,
+  signal?: AbortSignal,
+) => Promise<ReferenceResponse<T>>
+
+function request(
+  query: string,
+  options: { quoted?: boolean; signal?: AbortSignal } = {},
+): CandidateRequest {
+  return {
+    query,
+    position: 'inline',
+    signal: options.signal ?? new AbortController().signal,
+    ...options.quoted === undefined ? {} : { quoted: options.quoted },
+  }
+}
+
+async function bench(
+  files: ReferenceLookup<FileReferenceItem> = vi.fn(() => Promise.resolve({
+    result: {
+      ok: true as const,
+      value: {
+        items: [
+          { path: 'src', kind: 'directory' as const },
+          { path: 'docs/a b.md', kind: 'file' as const },
+        ],
+      },
+    },
+  })),
+  sessions: ReferenceLookup<SessionReferenceItem> = vi.fn(() => Promise.resolve({
+    result: {
+      ok: true as const,
+      value: {
+        items: [{
+          sessionId: sid('source'),
+          label: 'Research',
+          cwd: '/project',
+          createdAt: 1_700_000_000_000,
+          mention: '@[Research](dsh-session:InNvdXJjZSI)',
+        }],
+      },
+    },
+  })),
+): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']>; source: SlashSource }> {
+  const ctx = new Context()
+  let source: SlashSource | undefined
+  ctx.provide('slash', {
+    registerSource(candidate: SlashSource) {
+      source = candidate
+      return () => { source = undefined }
+    },
+  })
+  ctx.provide('connection', { api: { references: { files, sessions } } } as never)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+  if (source === undefined) throw new Error('reference source was not registered')
+  return { ctx, fiber, source }
+}
+
+describe('apply', () => {
+  it('declares its services and releases the @ reference registration on disposal', async () => {
+    expect(inject).toEqual(['slash', 'connection'])
+    const ctx = new Context()
+    let registered: SlashSource | undefined
+    ctx.provide('slash', {
+      registerSource(source: SlashSource) {
+        registered = source
+        return () => { registered = undefined }
+      },
+    })
+    ctx.provide('connection', {
+      api: {
+        references: {
+          files: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }),
+          sessions: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }),
+        },
+      },
+    } as never)
+    const fiber = ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(registered).toMatchObject({ trigger: '@', name: 'reference' })
+    await fiber.dispose()
+    expect(registered).toBeUndefined()
+  })
+})
+
+describe('candidates', () => {
+  it('starts both Host lookups together and renders files before sessions with TUI labels', async () => {
+    let releaseFiles!: () => void
+    let releaseSessions!: () => void
+    const files = vi.fn(() => new Promise<{
+      result: { ok: true; value: { items: { path: string; kind: 'file' | 'directory' }[] } }
+    }>((resolve) => {
+      releaseFiles = () => {
+        resolve({
+          result: {
+            ok: true,
+            value: {
+              items: [
+                { path: 'src', kind: 'directory' },
+                { path: 'docs/a b.md', kind: 'file' },
+              ],
+            },
+          },
+        })
+      }
+    }))
+    const sessions = vi.fn(() => new Promise<{
+      result: {
+        ok: true
+        value: {
+          items: {
+            sessionId: SessionId
+            label: string
+            cwd: string
+            createdAt: number
+            mention: string
+          }[]
+        }
+      }
+    }>((resolve) => {
+      releaseSessions = () => {
+        resolve({
+          result: {
+            ok: true,
+            value: {
+              items: [{
+                sessionId: sid('source'),
+                label: 'Research',
+                cwd: '/project',
+                createdAt: 1_700_000_000_000,
+                mention: '@[Research](dsh-session:InNvdXJjZSI)',
+              }],
+            },
+          },
+        })
+      }
+    }))
+    const { source } = await bench(files, sessions)
+    const pending = source.candidates(session, request('re'))
+    expect(files).toHaveBeenCalledTimes(1)
+    expect(sessions).toHaveBeenCalledTimes(1)
+    releaseSessions()
+    releaseFiles()
+    await expect(pending).resolves.toEqual([
+      expect.objectContaining({
+        name: 'Folder · src/',
+        description: 'src',
+        section: '文件与文件夹',
+      }),
+      expect.objectContaining({
+        name: 'File · a b.md',
+        description: 'docs/a b.md',
+        section: '文件与文件夹',
+      }),
+      expect.objectContaining({
+        name: 'Session · Research',
+        description: 'source · /project · 2023-11-14T22:13:20.000Z',
+        section: 'Session 对话',
+      }),
+    ])
+  })
+
+  it('suppresses sessions for an open quoted path and degrades each failed domain independently', async () => {
+    const files = vi.fn()
+      .mockResolvedValueOnce({
+        result: {
+          ok: true as const,
+          value: { items: [{ path: 'README.md', kind: 'file' as const }] },
+        },
+      })
+      .mockRejectedValueOnce(new Error('file scan failed'))
+    const sessions = vi.fn(() => Promise.resolve({
+      result: {
+        ok: true as const,
+        value: {
+          items: [{
+            sessionId: sid('source'),
+            label: 'Research',
+            cwd: '/project',
+            createdAt: 0,
+            mention: '@[Research](dsh-session:InNvdXJjZSI)',
+          }],
+        },
+      },
+    }))
+    const { source } = await bench(files, sessions)
+    const quoted = await source.candidates(session, request('READ', { quoted: true }))
+    expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })])
+    expect(source.onPick({
+      candidate: quoted[0]!,
+      session,
+      position: 'inline',
+      via: 'menu',
+      span: { start: 0, end: 6, draftRev: 1 },
+    })).toEqual({ text: '@"README.md" ' })
+    expect(sessions).not.toHaveBeenCalled()
+    await expect(source.candidates(session, request('research'))).resolves.toEqual([
+      expect.objectContaining({ name: 'Session · Research' }),
+    ])
+  })
+
+  it('drops a completed result when the query signal was superseded', async () => {
+    const controller = new AbortController()
+    const { source } = await bench()
+    const pending = source.candidates(session, request('', { signal: controller.signal }))
+    controller.abort()
+    await expect(pending).resolves.toEqual([])
+  })
+
+  it('treats Host errors as empty domains and filters paths that cannot be mentioned', async () => {
+    const files = vi.fn(() => Promise.resolve({
+      result: {
+        ok: true as const,
+        value: { items: [{ path: 'bad\nname', kind: 'file' as const }] },
+      },
+    }))
+    const sessions = vi.fn()
+      .mockRejectedValueOnce(new Error('session lookup failed'))
+      .mockResolvedValueOnce({
+        result: {
+          ok: false as const,
+          error: { code: 'reference-failed', message: 'session lookup failed' },
+        },
+      })
+    const { source } = await bench(files, sessions)
+    await expect(source.candidates(session, request('bad'))).resolves.toEqual([])
+
+    files.mockResolvedValueOnce({
+      result: {
+        ok: false as const,
+        error: { code: 'reference-failed', message: 'file lookup failed' },
+      },
+    } as never)
+    await expect(source.candidates(session, request('bad'))).resolves.toEqual([])
+  })
+
+  it('omits redundant session ids and labels sessions without a cwd', async () => {
+    const files = vi.fn(() => Promise.resolve({
+      result: { ok: true as const, value: { items: [] } },
+    }))
+    const sessions = vi.fn(() => Promise.resolve({
+      result: {
+        ok: true as const,
+        value: {
+          items: [{
+            sessionId: sid('same'),
+            label: 'same',
+            createdAt: 0,
+            mention: '@[same](dsh-session:InNhbWUi)',
+          }],
+        },
+      },
+    }))
+    const { source } = await bench(files, sessions)
+    await expect(source.candidates(session, request('same'))).resolves.toEqual([
+      expect.objectContaining({
+        name: 'Session · same',
+        description: '(no cwd) · 1970-01-01T00:00:00.000Z',
+      }),
+    ])
+  })
+})
+
+describe('pick and codec', () => {
+  const pick = (source: SlashSource, candidate: SlashCandidate) => source.onPick({
+    candidate,
+    session,
+    position: 'inline',
+    via: 'menu',
+    span: { start: 0, end: 1, draftRev: 1 },
+  })
+
+  it('inserts files as path text, keeping directory completion open', async () => {
+    const { source } = await bench()
+    const [directory, file] = await source.candidates(session, request(''))
+    expect(pick(source, directory!)).toEqual({ text: '@src/', continue: true })
+    expect(pick(source, file!)).toEqual({ text: '@"docs/a b.md" ' })
+    const [quotedDirectory] = await source.candidates(session, request('', { quoted: true }))
+    expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true })
+  })
+
+  it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => {
+    const { source } = await bench()
+    const candidates = await source.candidates(session, request(''))
+    const candidate = candidates.find(item => item.name === 'Session · Research')!
+    const mention = '@[Research](dsh-session:InNvdXJjZSI)'
+    expect(pick(source, candidate)).toEqual({
+      insert: {
+        source: 'reference',
+        ref: mention,
+        label: '@Research',
+        clipboardText: mention,
+      },
+    })
+    expect(source.codec?.clipboardText(mention)).toBe(mention)
+    await expect(source.codec?.serialize(mention, new AbortController().signal)).resolves.toBe(mention)
+  })
+
+  it('ignores candidates that do not carry a source-owned value', async () => {
+    const { source } = await bench()
+    expect(pick(source, { name: 'foreign candidate' })).toBeUndefined()
+  })
+})

+ 4 - 1
packages/client/ui-subagent/tsconfig.json → packages/client/ui-reference/tsconfig.json

@@ -11,6 +11,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../connection"
+    },
     {
       "path": "../runtime"
     },
@@ -18,7 +21,7 @@
       "path": "../ui-slash"
     },
     {
-      "path": "../ui-slots"
+      "path": "../../context/file-reference"
     },
     {
       "path": "../../support/invariants"

+ 3 - 0
packages/client/ui-reference/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 3 - 3
packages/client/ui-slash/README.i18n.yaml

@@ -1,6 +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
-README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
-README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
+#   pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
+README.md: 45bdff01605b51f18e08732930e8b35fec81f872
+README.zh.md: 6af98eb81edc66f5bf446f1b78e4b3dc6f0d1ce8

+ 5 - 3
packages/client/ui-slash/README.md

@@ -2,11 +2,13 @@
 
 English | [中文](README.zh.md)
 
-Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
+Input trigger pipeline plugin: `/` and `@` detection under the caret, the grouped candidate menu, and pick routing to registered sources. Slash detection keeps its word-boundary and guard-tier rules; `@` uses the shared TUI grammar and opens only at input start or after whitespace, including an unfinished `@"path with spaces` token. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
 
 Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
 
-MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
+MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. A candidate's optional `section` renders one non-selectable heading for each contiguous section without entering the keyboard-selection index. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
+
+Pick outcomes may insert plain text, an atomic reference chip, or request continued completion. Continued text picks replace the active token, then immediately retrack the resulting draft; `@file` directories use this path to keep completion open below the selected directory.
 
 The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
 
@@ -23,4 +25,4 @@ None; this package neither assembles nor sends a provider request.
 - **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need).
 - **`SlashCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships.
 - **Overlay SlotMap merge home is split from slot ownership** — the `conversation.input.overlay` merge lives here (sole copy) while the slot's owner semantics (anchor, children declaration, lifecycle) stay with ui-conversation; the dependency direction (ui-conversation → ui-slash) forces the split, so a future dependency reshuffle should revisit it.
-- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/subagent, revisit if business sources join.
+- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/reference, revisit if more business sources join.

+ 5 - 3
packages/client/ui-slash/README.zh.md

@@ -2,11 +2,13 @@
 
 [English](README.md) | 中文
 
-输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
+输入触发流水线插件:光标处的 `/` 与 `@` 检测、分组候选菜单,以及把 pick 路由到已注册 source。斜杠命令检测沿用其词边界与 guard tier 规则;`@` 使用 TUI 的共享语法,只会在输入开头或空白后打开,也能识别尚未闭合的 `@"path with spaces` token。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。流水线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
 
 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
 
-MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
+MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。候选项的可选 `section` 字段会为每段连续分组渲染一个不可选择的标题,且不会进入键盘选择索引。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
+
+pick 结果可以插入普通文本、原子引用 chip,或者请求继续补全。需要继续补全的文本 pick 会替换活跃 token,随后立即根据新草稿重新跟踪;`@file` 目录通过此路径让补全在所选目录下保持打开。
 
 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
 
@@ -23,4 +25,4 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类
 - **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
 - **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;接到设计系统图标枚举(iconFile 五变体家族)的接线等该枚举交付后落地。
 - **overlay 的 SlotMap 合并归属与 slot 所有权分离**:`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义(锚点、children 声明、生命周期)留在 ui-conversation;依赖方向(ui-conversation → ui-slash)迫使这一拆分,未来依赖关系调整时应重新审视。
-- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/subagent 时可以接受,业务 source 加入后需重新审视。
+- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/reference 时可以接受,更多业务 source 加入后需重新审视。

+ 2 - 0
packages/client/ui-slash/package.json

@@ -37,6 +37,7 @@
     "clsx": "^2.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-file-reference": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -44,6 +45,7 @@
     "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 14 - 0
packages/client/ui-slash/src/client/MenuView.module.css

@@ -44,6 +44,20 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+.sectionTitle {
+  flex: none;
+  min-height: 26px;
+  padding: 6px 10px 2px;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 18px;
+}
+
+.sectionTitle:not(:first-child) {
+  margin-top: 4px;
+}
+
 .itemIcon {
   display: inline-flex;
   flex: none;

+ 24 - 20
packages/client/ui-slash/src/client/MenuView.tsx

@@ -6,7 +6,7 @@
  * pattern — focus never leaves the textarea, so rows are mousedown-handled
  * and the highlight is exposed via aria-activedescendant on the listbox).
  */
-import { useSyncExternalStore } from 'react'
+import { Fragment, useSyncExternalStore } from 'react'
 import clsx from 'clsx'
 import css from './MenuView.module.css'
 import type { MenuViewInjected } from './slots.ts'
@@ -40,25 +40,29 @@ export function MenuView({ menu, onPick }: MenuViewInjected) {
         : group.items.map((item, index) => {
           const active = highlight !== null && highlight.source === group.source && highlight.index === index
           return (
-            <button
-              key={`${group.source}:${item.name}`}
-              id={optionId(group.source, index)}
-              type="button"
-              role="option"
-              aria-selected={active}
-              className={clsx(css.item, active && css.active)}
-              // mousedown, not click: the textarea keeps focus (combobox
-              // pattern) — preventing default stops the focus steal, and the
-              // pick runs before any blur-driven teardown.
-              onMouseDown={(ev) => {
-                ev.preventDefault()
-                onPick(group.source, index)
-              }}
-            >
-              {item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
-              <span className={css.itemName}>{item.name}</span>
-              {item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
-            </button>
+            <Fragment key={optionId(group.source, index)}>
+              {item.section !== undefined && item.section !== group.items[index - 1]?.section
+                ? <div className={css.sectionTitle} role="presentation">{item.section}</div>
+                : null}
+              <button
+                id={optionId(group.source, index)}
+                type="button"
+                role="option"
+                aria-selected={active}
+                className={clsx(css.item, active && css.active)}
+                // mousedown, not click: the textarea keeps focus (combobox
+                // pattern) — preventing default stops the focus steal, and the
+                // pick runs before any blur-driven teardown.
+                onMouseDown={(ev) => {
+                  ev.preventDefault()
+                  onPick(group.source, index)
+                }}
+              >
+                {item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
+                <span className={css.itemName}>{item.name}</span>
+                {item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
+              </button>
+            </Fragment>
           )
         }))}
     </div>

+ 12 - 2
packages/client/ui-slash/src/client/controller.ts

@@ -75,6 +75,7 @@ export class SlashController {
     const prev = this.menu.getSnapshot()
     const same = prev.open && prev.hit !== null
       && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query
+      && prev.hit.quoted === hit.quoted
       && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end
     this.hit = hit
     if (same) return
@@ -243,7 +244,11 @@ export class SlashController {
       return actx.bail(actx, 'slash/input-begin-command', { claim: outcome.claim, span }) === true
     }
     if ('text' in outcome) {
-      return actx.bail(actx, 'slash/input-insert-text', { text: outcome.text, span }) === true
+      return actx.bail(actx, 'slash/input-insert-text', {
+        text: outcome.text,
+        span,
+        ...outcome.continue === true ? { continue: true } : {},
+      }) === true
     }
     return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
   }
@@ -278,7 +283,12 @@ export class SlashController {
     const projection = this.project()
     for (const source of roster) {
       void source
-        .candidates(projection, { query: hit.query, position: hit.position, signal: controller.signal })
+        .candidates(projection, {
+          query: hit.query,
+          quoted: hit.quoted,
+          position: hit.position,
+          signal: controller.signal,
+        })
         .then(
           (items) => {
             if (controller.signal.aborted) return

+ 5 - 2
packages/client/ui-slash/src/core/contract.ts

@@ -11,6 +11,8 @@ export interface TriggerHit {
   readonly trigger: TriggerChar
   /** Text between the trigger char and the caret, live-filtered. */
   readonly query: string
+  /** True only for an open quoted `@file` token. */
+  readonly quoted: boolean
   /** leading = draft trimmed (whitespace incl. newlines) starts with the token. */
   readonly position: TriggerPosition
   /** Token span; draftRev injected by the caller. */
@@ -19,8 +21,9 @@ export interface TriggerHit {
 
 /**
  * Detect a trigger token at the caret under the given guard tier.
- * Word-boundary rule: the char before the trigger is start-of-line,
- * whitespace, or punctuation; `user@host` and URL '/' do not trigger.
+ * `@` uses the shared file-reference start/whitespace grammar; `/` accepts
+ * punctuation boundaries with URL carve-outs. `user@host` and URL `/` do not
+ * trigger.
  * Returns null when no trigger is live at the caret.
  */
 export type DetectTrigger = (draft: string, caret: number, guard: TriggerGuard) => TriggerHit | null

+ 19 - 6
packages/client/ui-slash/src/core/detect.ts

@@ -3,6 +3,7 @@
  * the caret for a live trigger char under the guard tier and applies the
  * word-boundary rules. Zero React / DOM / cordis.
  */
+import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar'
 import type { TriggerChar } from '../types.ts'
 import type { DetectTrigger } from './contract.ts'
 
@@ -29,10 +30,10 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
 }
 
 /**
- * Detect a trigger token at the caret. Scans left from the caret and stops
- * at the first whitespace (the token under edit never spans whitespace);
- * trigger chars failing the guard tier or the word boundary are treated as
- * ordinary token chars and the scan continues (`user@host`, URL slashes).
+ * Detect a trigger token at the caret. `@` first uses the shared grammar,
+ * including an open quoted token that may span whitespace. Slash detection
+ * scans left to the first whitespace; slashes failing the word boundary are
+ * treated as ordinary token chars and the scan continues (URL slashes).
  * Guard tiers: plain = both chars live; claimed = '/' fully suppressed,
  * '@' live; frozen = none.
  *
@@ -46,15 +47,27 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
  */
 export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
   if (guard.tier === 'frozen') return null
+  const at = activeAtToken(draft, caret)
+  if (at !== undefined) {
+    const start = caret - at.prefix.length
+    return {
+      trigger: '@',
+      query: at.query,
+      quoted: at.quoted,
+      position: draft.search(/\S/) === start ? 'leading' : 'inline',
+      span: { start, end: caret, draftRev: 0 },
+    }
+  }
   for (let i = caret - 1; i >= 0; i--) {
     const ch = draft.charAt(i)
     if (WHITESPACE.test(ch)) return null
-    if (ch !== '/' && ch !== '@') continue
-    if (guard.tier === 'claimed' && ch === '/') continue
+    if (ch !== '/') continue
+    if (guard.tier === 'claimed') continue
     if (!boundaryOk(draft, i, ch)) continue
     return {
       trigger: ch,
       query: draft.slice(i + 1, caret),
+      quoted: false,
       position: draft.search(/\S/) === i ? 'leading' : 'inline',
       span: { start: i, end: caret, draftRev: 0 },
     }

+ 10 - 2
packages/client/ui-slash/src/types.ts

@@ -1,6 +1,6 @@
 /**
  * Frozen cross-package contract for the input trigger pipeline. Types only —
- * no runtime code. Sources (ui-command / ui-skill / ui-subagent) and the
+ * no runtime code. Sources (ui-command / ui-skill / ui-reference) and the
  * conversation input layer import from here; changes require main-thread
  * arbitration.
  *
@@ -33,6 +33,10 @@ export type PickVia = 'menu' | 'space' | 'enter'
 /** One menu candidate. Pure display data — zero behavior declaration. */
 export interface SlashCandidate {
   readonly name: string
+  /** Source-owned stable value when the display name is not the identity. */
+  readonly value?: string
+  /** Presentation-only heading rendered once for each contiguous section. */
+  readonly section?: string
   readonly description?: string
   readonly icon?: string
   readonly hint?: string
@@ -89,13 +93,15 @@ export interface SubmitOutcome {
 export type PickOutcome =
   | { readonly claim: CommandClaim }
   | { readonly insert: ReferenceInsert }
-  | { readonly text: string }
+  | { readonly text: string; readonly continue?: boolean }
   | 'handled'
   | undefined
 
 /** Candidate request passed to a source. The signal is superseded on query change / menu close. */
 export interface CandidateRequest {
   readonly query: string
+  /** True only for the open `@"path with spaces` grammar. */
+  readonly quoted?: boolean
   readonly position: TriggerPosition
   readonly signal: AbortSignal
 }
@@ -204,6 +210,8 @@ export interface ConsumeTokenRequest {
 export interface InsertTextRequest {
   /** Literal replacement for the trigger token span (e.g. `/name `). */
   readonly text: string
+  /** Re-run trigger detection after insertion (directory descent). */
+  readonly continue?: boolean
   readonly span: TokenSpan
 }
 

+ 11 - 0
packages/client/ui-slash/tests/core-detect.spec.ts

@@ -91,6 +91,17 @@ describe('detectTrigger guard tiers', () => {
 })
 
 describe('detectTrigger span and query', () => {
+  it('keeps an open quoted @file token active across spaces', () => {
+    const draft = 'read @"docs/design notes'
+    expect(atEnd(draft)).toMatchObject({
+      trigger: '@',
+      query: 'docs/design notes',
+      quoted: true,
+      position: 'inline',
+      span: { start: 5, end: draft.length },
+    })
+  })
+
   it('spans trigger char to caret with a placeholder draftRev', () => {
     const hit = detectTrigger('say /goal', 9, plain)
     expect(hit?.span).toEqual({ start: 4, end: 9, draftRev: 0 })

+ 1 - 0
packages/client/ui-slash/tests/core-menu.spec.ts

@@ -8,6 +8,7 @@ import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/men
 const hit = (query = ''): TriggerHit => ({
   trigger: '/',
   query,
+  quoted: false,
   position: 'leading',
   span: { start: 0, end: 1 + query.length, draftRev: 1 },
 })

+ 26 - 0
packages/client/ui-slash/tests/menu-view.spec.tsx

@@ -14,6 +14,7 @@ import { MenuView } from '../src/client/MenuView.tsx'
 const hit: TriggerHit = {
   trigger: '/',
   query: 'g',
+  quoted: false,
   position: 'leading',
   span: { start: 0, end: 2, draftRev: 1 },
 }
@@ -60,6 +61,31 @@ describe('MenuView', () => {
     expect(screen.queryByText('Loading skill…')).not.toBeNull()
   })
 
+  it('renders contiguous candidate sections once without changing option indexes', () => {
+    const { onPick } = mount(openState({
+      groups: [{
+        source: 'reference',
+        status: 'ready',
+        items: [
+          { name: 'Folder · src/', section: '文件与文件夹' },
+          { name: 'File · README.md', section: '文件与文件夹' },
+          { name: 'Session · Research', section: 'Session 对话' },
+        ],
+      }],
+      highlight: { source: 'reference', index: 0 },
+    }))
+    expect(screen.getAllByText('文件与文件夹')).toHaveLength(1)
+    expect(screen.getAllByText('Session 对话')).toHaveLength(1)
+    const options = screen.getAllByRole('option')
+    expect(options.map(option => option.textContent)).toEqual([
+      'Folder · src/',
+      'File · README.md',
+      'Session · Research',
+    ])
+    fireEvent.mouseDown(options[2]!)
+    expect(onPick).toHaveBeenCalledWith('reference', 2)
+  })
+
   it('exposes the highlight via aria-activedescendant and aria-selected', () => {
     mount(openState({ highlight: { source: 'command', index: 1 } }))
     const listbox = screen.getByRole('listbox')

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

@@ -14,6 +14,9 @@
     {
       "path": "../runtime"
     },
+    {
+      "path": "../../context/file-reference"
+    },
     {
       "path": "../ui-slots"
     },

+ 0 - 31
packages/client/ui-subagent/README.md

@@ -1,31 +0,0 @@
-# @deepseek-ai/dsh-client-ui-subagent
-
-English | [中文](README.zh.md)
-
-Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink.
-
-A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work.
-
-The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
-
-## Model Experience
-
-### Subagent label text in the user prompt
-
-#### What the model sees
-
-A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided.
-
-#### Token effect
-
-Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser).
-
-#### KV Cache effect
-
-Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
-
-## Known Limitations and Deferred Work
-
-- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger.
-- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents).
-- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids.

+ 0 - 31
packages/client/ui-subagent/README.zh.md

@@ -1,31 +0,0 @@
-# @deepseek-ai/dsh-client-ui-subagent
-
-[English](README.md) | 中文
-
-subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running`、`displayTitle` 包含 query);pick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace`/`matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。
-
-没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering(中途引导)、恢复已 dispose 的子会话)是未来的业务工作。
-
-`/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。
-
-## 模型体验
-
-### 用户提示词中的 subagent label 文本
-
-#### 模型所见
-
-被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。
-
-#### Token 影响
-
-有条件且极小:只有 pick(或手动键入相同文本)会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token(候选永不离开浏览器)。
-
-#### KV Cache 影响
-
-仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
-
-## 已知限制与暂缓事项
-
-- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话的 steering/发消息(以及是否允许恢复已 dispose 的子会话),等待台账中它自己的设计决策。
-- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现,roster 只含 scope 所指会话的直接子会话(不含孙辈,不含跨会话 agent)。
-- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可以接受;消费功能必须绑定到会话 id。

+ 0 - 58
packages/client/ui-subagent/src/client/index.ts

@@ -1,58 +0,0 @@
-/**
- * Subagent reference plugin, browser half: registers the '@' source —
- * candidates filtered from the session list snapshot's running children
- * (zero RPC; the list rides the plugin's root-context sessions service, the
- * scoped session comes from the per-call projection), pick inserts the
- * literal `@label ` text (decision 21: the draft carries plain text, chip
- * visuals are derived by scanning against the source lexicon, and the
- * prompt ships the same literal). Consumption semantics stay with future
- * business work (design ledger). No adjudication hooks: subagent
- * references never enter command adjudication.
- */
-import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
-
-/** Required services: the slash registry + the session list face the source closes over. */
-export const inject = ['slash', 'sessions']
-
-/**
- * Client plugin body: register the '@' subagent source over the root session list.
- * @param ctx - client root context.
- */
-export function apply(ctx: ClientContext): void {
-  const sessions = ctx.get('sessions') as SessionsService
-  // Child labels live on the session list (parentId lineage + displayTitle),
-  // not the conversation snapshot — the list store is the zero-RPC candidate feed.
-  const childLabels = (session: ClientSessionContext, query: string): string[] => {
-    const { byId } = sessions.list.getSnapshot()
-    return Object.values(byId)
-      .filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
-      .map(child => child.displayTitle)
-  }
-  const source: SlashSource = {
-    trigger: '@',
-    name: 'subagent',
-    candidates(session, { query }) {
-      return Promise.resolve(childLabels(session, query).map(name => ({ name })))
-    },
-    lexicon(session) {
-      // The list snapshot is always warm — the full running-children roster.
-      return childLabels(session, '')
-    },
-    onPick({ candidate }) {
-      // Decision 21: plain-text reference — the literal lands in the draft
-      // and ships to the model verbatim (trailing space closes the token).
-      // Legacy path (decision 21), retained for the removal cut, no longer reached:
-      // return { insert: { source: 'subagent', ref: candidate.name, label: candidate.name, clipboardText: `@${candidate.name}` } }
-      return { text: `@${candidate.name} ` }
-    },
-    codec: {
-      clipboardText: ref => `@${ref}`,
-      // TODO: serialize returns the raw label until the '@' consumption
-      // feature defines a model representation (design ledger).
-      serialize: ref => Promise.resolve(`@${ref}`),
-    },
-  }
-  const slash = ctx.get('slash') as SlashServiceContract
-  ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source')
-}

+ 0 - 6
packages/client/ui-subagent/src/css-modules.d.ts

@@ -1,6 +0,0 @@
-declare module '*.module.css' {
-  const classes: Record<string, string>
-  export default classes
-}
-
-declare module '*.css'

+ 0 - 145
packages/client/ui-subagent/tests/browser-plugin.spec.ts

@@ -1,145 +0,0 @@
-/**
- * ui-subagent browser half: source registration (duplicate-name proof) +
- * fiber-teardown removal (HMR safety) against the real SlashService, then
- * the source behavior contract driven directly on the captured source with
- * real ClientSessionContext projections — zero-RPC candidates from the root
- * session list (running children of the projected session, label-contains
- * filtering, childless session → empty), the synchronous lexicon roster,
- * pick → plain-text outcome (decision 21), and the reference codec's two
- * projections. Direct driving is deliberate: this spec owns only the
- * source's own contract.
- */
-import { Context } from 'cordis'
-import { describe, expect, it } from 'vitest'
-import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
-import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
-import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
-import { apply, inject } from '../src/client/index.ts'
-
-function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
-  return {
-    displayTitle: partial.id,
-    running: false,
-    updatedAt: 0,
-    ...partial,
-  } as SessionSummary
-}
-
-const sid = (id: string) => id as SessionId
-
-/** Fake root sessions face: the list snapshot the source closes over. */
-function sessionsWith(sessions: SessionSummary[]) {
-  const byId: Record<string, SessionSummary> = {}
-  for (const s of sessions) byId[s.id] = s
-  const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
-  return { list: { getSnapshot: () => snapshot } }
-}
-
-/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
-async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
-  const ctx = new Context()
-  let captured: SlashSource | undefined
-  ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
-  ctx.provide('sessions', sessionsWith(sessions))
-  await ctx.plugin({ inject: [...inject], apply }).await()
-  return captured!
-}
-
-const FAMILY: SessionSummary[] = [
-  summary({ id: sid('parent'), displayTitle: 'parent', running: true }),
-  summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }),
-  summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }),
-  // Filtered out: not running / other parent / label miss.
-  summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }),
-  summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }),
-  summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }),
-]
-
-const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
-
-const req = (query: string) =>
-  ({ query, position: 'inline' as const, signal: new AbortController().signal })
-
-describe('apply', () => {
-  it('declares the services it binds', () => {
-    expect(inject).toEqual(['slash', 'sessions'])
-  })
-
-  it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SlashService).await()
-    ctx.provide('sessions', sessionsWith(FAMILY))
-    const fiber = ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    const slash = ctx.get('slash') as SlashService
-    const rival = {
-      trigger: '@' as const,
-      name: 'subagent',
-      candidates: () => Promise.resolve([]),
-      onPick: () => undefined,
-    }
-    // Live registration holds the (trigger, name) seat…
-    expect(() => slash.registerSource(rival)).toThrow(/already registered/)
-    // …and fiber teardown releases it.
-    await fiber.dispose()
-    expect(() => slash.registerSource(rival)).not.toThrow()
-  })
-})
-
-describe('candidates', () => {
-  it('returns running children of the projected session, filtered by label containment', async () => {
-    const source = await bench(FAMILY)
-    await expect(source.candidates(proj('parent'), req('worker'))).resolves.toEqual([
-      { name: 'worker-1' }, { name: 'worker-2' },
-    ])
-  })
-
-  it('matches every running child on an empty query (containment, not prefix)', async () => {
-    const source = await bench(FAMILY)
-    await expect(source.candidates(proj('parent'), req(''))).resolves.toEqual([
-      { name: 'worker-1' }, { name: 'worker-2' }, { name: 'scout' },
-    ])
-  })
-
-  it('is candidate-less for a session with no children', async () => {
-    const source = await bench(FAMILY)
-    await expect(source.candidates(proj('childless'), req(''))).resolves.toEqual([])
-  })
-})
-
-describe('lexicon', () => {
-  it('synchronously serves the projected session\'s full running-children roster', async () => {
-    const source = await bench(FAMILY)
-    expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
-    expect(source.lexicon!(proj('childless'))).toEqual([])
-  })
-})
-
-describe('pick and codec', () => {
-  it('onPick returns the literal @label text with a closing space (decision 21)', async () => {
-    const source = await bench(FAMILY)
-    const outcome = source.onPick({
-      candidate: { name: 'worker-1' },
-      session: proj('parent'),
-      position: 'inline',
-      via: 'menu',
-      span: { start: 4, end: 8, draftRev: 3 },
-    })
-    expect(outcome).toEqual({ text: '@worker-1 ' })
-  })
-
-  it('codec projects clipboard `@label` and serializes the same raw label this phase', async () => {
-    const source = await bench(FAMILY)
-    expect(source.codec!.clipboardText('worker-1')).toBe('@worker-1')
-    await expect(source.codec!.serialize('worker-1', new AbortController().signal))
-      .resolves.toBe('@worker-1')
-  })
-})
-
-describe('adjudication', () => {
-  it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => {
-    const source = await bench(FAMILY)
-    expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false)
-    expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false)
-  })
-})

+ 0 - 3
packages/client/ui-subagent/tsdown.config.ts

@@ -1,3 +0,0 @@
-import { clientBundle } from '../tsdown.client.ts'
-
-export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 6 - 0
packages/context/file-reference-local/README.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 packages/context/file-reference-local/README.md
+README.md: 67b07eef4b59fdcc5e21104cac4628feb1c15f4e
+README.zh.md: beded13250daf041294e4e2656d0e1374407ff94

+ 45 - 0
packages/context/file-reference-local/README.md

@@ -0,0 +1,45 @@
+# `@deepseek-ai/dsh-file-reference-local`
+
+English | [中文](README.zh.md)
+
+Local-filesystem implementation of `ctx.fileReferences`. It maintains one bounded `WorkspaceFileSearch` per agent, rooted at that session's `cwd` and falling back to the host process cwd. The index ranks direct directory listings for queries containing `/`, otherwise fuzzy-ranks a bounded recursive index; it never follows directory symlinks.
+
+Tool-result events invalidate the addressed agent's reusable index so later completion observes likely workspace mutations. Agent disposal releases that index and its scoped prompt contribution; plugin disposal awaits every prompt fiber and releases all cached searches.
+
+## Configuration
+
+| Key | Default | Contract |
+|---|---:|---|
+| `maxResults` | `20` | Maximum ranked candidates returned for one query. |
+| `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace. |
+| `excludedDirectories` | `[".git", "node_modules"]` | Directory basenames omitted from traversal and candidates. |
+
+Every numeric value must be a positive safe integer. Excluded names must be non-empty basenames without `/` or `\`.
+
+## Model Experience
+
+### File-reference guidance when `read` is available
+
+#### What the model sees
+
+When the addressed agent has an effective `read` tool, the provider contributes this stable system-prompt section:
+
+##### File-reference instruction
+
+```markdown
+Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
+```
+
+#### Token effect
+
+Conditional and fixed: the one sentence is present while `read` is visible to the addressed agent; candidate lookup itself adds no tokens, and a selected path contributes only its ordinary user-message characters.
+
+#### KV Cache effect
+
+The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not.
+
+## Known Limitations and Deferred Work
+
+- **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool.
+- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear.
+- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded.

+ 45 - 0
packages/context/file-reference-local/README.zh.md

@@ -0,0 +1,45 @@
+# `@deepseek-ai/dsh-file-reference-local`
+
+[English](README.md) | 中文
+
+`ctx.fileReferences` 的本地文件系统实现。它为每个 agent(智能体)维护一个有界的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根目录;缺少该值时回退到宿主进程的 cwd。查询包含 `/` 时,索引会对直接列出的目录项排序;否则会对有界递归索引进行模糊排序。索引永远不会跟随目录符号链接。
+
+工具结果事件会使指定 agent 的可复用索引失效,使后续补全能够反映工作区中可能发生的变更。agent 的 dispose(资源释放)会释放该索引及其作用域内的提示词贡献;插件 dispose 会等待所有提示词 fiber,并释放全部缓存的搜索器。
+
+## 配置
+
+| 配置键 | 默认值 | 契约 |
+|---|---:|---|
+| `maxResults` | `20` | 单次查询返回的候选项最大数量。 |
+| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件和目录最大数量。 |
+| `excludedDirectories` | `[".git", "node_modules"]` | 遍历和候选项中排除的目录基名。 |
+
+所有数值都必须是正的安全整数。排除名称必须是非空基名,且不能包含 `/` 或 `\`。
+
+## 模型体验
+
+### `read` 可用时的文件引用指引
+
+#### 模型看到什么
+
+当指定 agent 有实际生效的 `read` 工具时,提供方会贡献以下稳定的系统提示词段:
+
+##### 文件引用指令
+
+```markdown
+Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
+```
+
+#### Token 影响
+
+该影响有条件且固定:只要 `read` 对指定 agent 可见,这一句就会存在;候选查询本身不增加 token,所选路径只会贡献普通用户消息中的对应字符。
+
+#### KV 缓存影响
+
+该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。
+
+## 已知限制与暂缓事项
+
+- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。
+- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。
+- **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。

+ 53 - 0
packages/context/file-reference-local/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "@deepseek-ai/dsh-file-reference-local",
+  "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./search": {
+      "types": "./lib/types/search.d.ts",
+      "default": "./lib/types/search.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-file-reference": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-file-reference": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 140 - 0
packages/context/file-reference-local/src/index.ts

@@ -0,0 +1,140 @@
+/**
+ * Local-filesystem implementation of `ctx.fileReferences`.
+ *
+ * @module @deepseek-ai/dsh-file-reference-local
+ */
+
+import { Context } from 'cordis'
+import z from 'schemastery'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import FileReferenceService, {
+  FILE_REFERENCE_PROMPT,
+  type FileReferenceCandidate,
+} from '@deepseek-ai/dsh-file-reference'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+import type {} from '@deepseek-ai/dsh-tools'
+import {
+  DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
+  DEFAULT_FILE_SEARCH_MAX_ENTRIES,
+  DEFAULT_FILE_SEARCH_MAX_RESULTS,
+  WorkspaceFileSearch,
+  type FileSearchConfig,
+} from './search.ts'
+
+export {
+  DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
+  DEFAULT_FILE_SEARCH_MAX_ENTRIES,
+  DEFAULT_FILE_SEARCH_MAX_RESULTS,
+  WorkspaceFileSearch,
+} from './search.ts'
+export type { FileSearchConfig } from './search.ts'
+export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference'
+export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar'
+
+/** Local file-reference discovery configuration. */
+export interface Config {
+  /** Maximum ranked candidates returned for one query. */
+  maxResults?: number
+  /** Maximum indexed files and directories per agent workspace. */
+  maxEntries?: number
+  /** Directory basenames never traversed or offered. */
+  excludedDirectories?: string[]
+}
+
+/** Local-filesystem owner of the file-reference discovery service. */
+export class LocalFileReferenceService extends FileReferenceService {
+  static inject = ['agents']
+  static Config: z<Config> = z.object({
+    maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS),
+    maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES),
+    excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]),
+  })
+
+  private readonly config: FileSearchConfig
+  private readonly searches = new Map<Agent, WorkspaceFileSearch>()
+  private readonly promptFibers = new Map<Agent, ReturnType<Context['inject']>>()
+  private readonly promptDisposals = new Set<Promise<void>>()
+
+  constructor(ctx: Context, config: Config = {}) {
+    super(ctx)
+    this.config = {
+      maxResults: config.maxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
+      maxEntries: config.maxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
+      excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
+    }
+    validateConfig(this.config)
+
+    const installPrompt = (agent: Agent): void => {
+      if (this.promptFibers.has(agent)) return
+      const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => {
+        scope.systemPrompt.section({
+          name: 'context:file-reference',
+          order: 99,
+          text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
+        })
+      })
+      this.promptFibers.set(agent, fiber)
+    }
+    const disposePrompt = (agent: Agent): void => {
+      const fiber = this.promptFibers.get(agent)
+      if (fiber === undefined) return
+      this.promptFibers.delete(agent)
+      const task = fiber.dispose().catch((error: unknown) => {
+        ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`)
+      })
+      this.promptDisposals.add(task)
+      void task.finally(() => {
+        this.promptDisposals.delete(task)
+      })
+    }
+    for (const agent of ctx.agents.list()) installPrompt(agent)
+    ctx.on('agent/created', installPrompt)
+    ctx.on('agent/disposed', (agent) => {
+      this.searches.get(agent)?.dispose()
+      this.searches.delete(agent)
+      disposePrompt(agent)
+    })
+    ctx.on('session/event', (session, event) => {
+      if (event.type !== 'tool/result') return
+      const agent = ctx.agents.get(session.id)
+      if (agent !== undefined) this.searches.get(agent)?.invalidate()
+    })
+    ctx.effect(() => async () => {
+      for (const search of this.searches.values()) search.dispose()
+      this.searches.clear()
+      const promptFibers = [...this.promptFibers.values()]
+      this.promptFibers.clear()
+      await Promise.all([
+        ...promptFibers.map(fiber => fiber.dispose()),
+        ...this.promptDisposals,
+      ])
+    }, 'file-reference-local: search cache')
+  }
+
+  override list(
+    agent: Agent,
+    query: string,
+    signal: AbortSignal,
+  ): Promise<FileReferenceCandidate[]> {
+    let search = this.searches.get(agent)
+    if (search === undefined) {
+      search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config)
+      this.searches.set(agent, search)
+    }
+    return search.list(query, signal)
+  }
+}
+
+function validateConfig(config: FileSearchConfig): void {
+  if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
+    throw new Error('file-reference-local: maxResults must be a positive safe integer')
+  }
+  if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
+    throw new Error('file-reference-local: maxEntries must be a positive safe integer')
+  }
+  if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
+    throw new Error('file-reference-local: excludedDirectories entries must be non-empty directory basenames')
+  }
+}
+
+export default LocalFileReferenceService

+ 30 - 0
packages/context/file-reference-local/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`.
+ * @module @deepseek-ai/dsh-file-reference-local/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference-local'
+
+/** Cordis companion plugin name. */
+export const name = 'file-reference-local-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: per-agent indexes are private advisory caches whose
+ * invalidation and disposal are observed directly through service tests.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 17 - 69
packages/ui/tui/src/file-autocomplete.ts → packages/context/file-reference-local/src/search.ts

@@ -1,13 +1,16 @@
 /**
- * Host-workspace discovery for TUI `@file` completion. The index contains
- * paths only: selected values remain ordinary prompt text and file contents
- * stay behind the model-facing `read` tool.
+ * Host-workspace discovery for `@file` completion. The index contains paths
+ * only: selected values remain ordinary prompt text and file contents stay
+ * behind the model-facing `read` tool.
  *
- * @module @deepseek-ai/dsh-tui/file-autocomplete
+ * @module @deepseek-ai/dsh-file-reference-local/search
  */
 
 import { lstat, readdir } from 'node:fs/promises'
 import { isAbsolute, join, relative, resolve, sep } from 'node:path'
+import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference'
+
+export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar'
 
 /** Default maximum file and directory candidates rendered for one query. */
 export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
@@ -16,7 +19,7 @@ export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
 /** Directory basenames omitted from traversal unless the deployment overrides them. */
 export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
 
-/** Resolved limits and exclusions for one TUI workspace index. */
+/** Resolved limits and exclusions for one workspace index. */
 export interface FileSearchConfig {
   /** Maximum ranked candidates returned for one query. */
   maxResults: number
@@ -26,28 +29,10 @@ export interface FileSearchConfig {
   excludedDirectories: readonly string[]
 }
 
-/** One path-only completion candidate inside the session cwd. */
-export interface FileSearchCandidate {
-  /** User-facing path accepted by the normal prompt and filesystem tools. */
-  path: string
-  /** Directories keep completion open; files finish the mention. */
-  kind: 'file' | 'directory'
-}
-
-/** Active `@` token ending at the editor cursor. */
-export interface ActiveAtToken {
-  /** Complete token replaced when the user accepts a completion. */
-  prefix: string
-  /** Path query after `@` or `@"`. */
-  query: string
-  /** Whether the user opened a quoted path. */
-  quoted: boolean
-}
-
-interface IndexedPath extends FileSearchCandidate {}
+interface IndexedPath extends FileReferenceCandidate {}
 
 interface RankedPath {
-  candidate: FileSearchCandidate
+  candidate: FileReferenceCandidate
   score: number
 }
 
@@ -56,43 +41,6 @@ interface IndexGeneration {
   promise: Promise<IndexedPath[]>
 }
 
-/**
- * Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
- * inside another token, such as an email address, is not a completion trigger.
- * @param line - current editor line.
- * @param cursorCol - cursor column within that line.
- * @returns the active token, or `undefined` outside an `@` token.
- */
-export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
-  const beforeCursor = line.slice(0, cursorCol)
-  const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
-  if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
-    return { prefix: quoted[1], query: quoted[2], quoted: true }
-  }
-  const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
-  if (plain?.[1] === undefined || plain[2] === undefined) return undefined
-  return { prefix: plain[1], query: plain[2], quoted: false }
-}
-
-/**
- * Format a selected path as prompt text. Whitespace uses Pi's quoted
- * `@"path"` grammar; directories retain a trailing slash so completion can
- * descend another level.
- * @param candidate - selected file or directory.
- * @param preserveQuote - retain an explicitly opened quote even when unnecessary.
- * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
- */
-export function formatFileMention(
-  candidate: FileSearchCandidate,
-  preserveQuote: boolean,
-): string | undefined {
-  const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
-  if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
-  const quoted = preserveQuote || /\s/u.test(path)
-  if (!quoted) return `@${path}`
-  return `@"${path}"`
-}
-
 /**
  * Cancellable, reusable fuzzy index rooted at one agent working directory.
  * Directory-scoped queries list live state; bare fuzzy queries share one
@@ -125,7 +73,7 @@ export class WorkspaceFileSearch {
    * @param signal - cancels this caller's wait without killing an index shared by a newer query.
    * @returns at most `maxResults` deterministic candidates.
    */
-  async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
+  async list(rawQuery: string, signal: AbortSignal): Promise<FileReferenceCandidate[]> {
     signal.throwIfAborted()
     if (this.disposed) return []
     const query = rawQuery.replaceAll('\\', '/')
@@ -203,12 +151,12 @@ export class WorkspaceFileSearch {
     displayDirectory: string,
     fragment: string,
     signal: AbortSignal,
-  ): Promise<FileSearchCandidate[]> {
+  ): Promise<FileReferenceCandidate[]> {
     if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
     const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal)
     if (absolute === undefined) return []
     const entries = await readDirectory(absolute, signal)
-    const candidates: FileSearchCandidate[] = []
+    const candidates: FileReferenceCandidate[] = []
     for (const entry of entries) {
       if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
       if (entry.isDirectory()) {
@@ -269,10 +217,10 @@ function visibleForGlobalQuery(path: string, query: string): boolean {
 }
 
 function rankCandidates(
-  candidates: readonly FileSearchCandidate[],
+  candidates: readonly FileReferenceCandidate[],
   query: string,
   limit: number,
-): FileSearchCandidate[] {
+): FileReferenceCandidate[] {
   const ranked: RankedPath[] = []
   for (const candidate of candidates) {
     const score = scoreCandidate(candidate, query)
@@ -286,7 +234,7 @@ function rankCandidates(
   return ranked.slice(0, limit).map(entry => entry.candidate)
 }
 
-function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
+function scoreCandidate(candidate: FileReferenceCandidate, query: string): number | undefined {
   if (query === '') return 0
   const path = candidate.path.toLowerCase()
   const name = path.slice(path.lastIndexOf('/') + 1)
@@ -312,7 +260,7 @@ function subsequenceScore(target: string, query: string): number | undefined {
   return Math.max(0, 100 - gap)
 }
 
-function kindRank(kind: FileSearchCandidate['kind']): number {
+function kindRank(kind: FileReferenceCandidate['kind']): number {
   return kind === 'directory' ? 0 : 1
 }
 

+ 12 - 0
packages/context/file-reference-local/tests/invariant.spec.ts

@@ -0,0 +1,12 @@
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as FileReferenceLocalInvariant from '../src/invariant.ts'
+
+describe('invariant companion', () => {
+  it('registers the provider cache ownership under its package name', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantService, { enabled: true })
+    await expect(ctx.plugin(FileReferenceLocalInvariant).await()).resolves.toBeDefined()
+  })
+})

+ 4 - 2
packages/ui/tui/tests/file-autocomplete.spec.ts → packages/context/file-reference-local/tests/search.spec.ts

@@ -6,7 +6,7 @@ import {
   activeAtToken,
   formatFileMention,
   WorkspaceFileSearch,
-} from '../src/file-autocomplete.ts'
+} from '../src/search.ts'
 
 const searches: WorkspaceFileSearch[] = []
 const roots: string[] = []
@@ -48,7 +48,7 @@ afterEach(async () => {
   await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
 })
 
-describe('TUI file autocomplete grammar', () => {
+describe('file-reference grammar', () => {
   it('recognizes boundary and quoted mentions without treating emails as references', () => {
     expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false })
     expect(activeAtToken('read @"docs/design n', 20)).toEqual({
@@ -65,6 +65,8 @@ describe('TUI file autocomplete grammar', () => {
     expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/')
     expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false))
       .toBe('@"docs/design notes.md"')
+    expect(formatFileMention({ path: 'docs/design notes', kind: 'directory' }, false))
+      .toBe('@"docs/design notes/')
     expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"')
     expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined()
     expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined()

+ 161 - 0
packages/context/file-reference-local/tests/service.spec.ts

@@ -0,0 +1,161 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
+import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference'
+import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts'
+
+const roots: string[] = []
+
+afterEach(async () => {
+  vi.restoreAllMocks()
+  await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
+})
+
+async function harness(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt, { persona: '' })
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(AgentRegistry)
+  return ctx
+}
+
+async function stubAgent(
+  ctx: Context,
+  id = 'file-reference-agent',
+  includeCwd = true,
+): Promise<{ agent: Agent; dispose: () => void }> {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-'))
+  roots.push(root)
+  await writeFile(join(root, 'README.md'), 'readme')
+  const session = ctx.sessions.create(SessionId(id), { meta: includeCwd ? { cwd: root } : {} })
+  const agent = {
+    id: session.id,
+    options: {},
+    session,
+    status: 'idle',
+    ctx,
+    followup: () => AgentMessageId('followup'),
+    queue: () => AgentMessageId('queue'),
+    steer: () => AgentMessageId('steer'),
+    inject: () => AgentMessageId('inject'),
+    send: () => AgentMessageId('send'),
+    cancel() {},
+    whenIdle: () => Promise.resolve(),
+  } as Agent
+  return { agent, dispose: ctx.agents.register(agent) }
+}
+
+describe('LocalFileReferenceService', () => {
+  it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => {
+    const ctx = await harness()
+    const { agent } = await stubAgent(ctx)
+    const fiber = ctx.plugin(LocalFileReferenceService, {
+      maxResults: 5,
+      maxEntries: 100,
+      excludedDirectories: ['.git'],
+    })
+    await fiber
+    await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal))
+      .resolves.toEqual([{ path: 'README.md', kind: 'file' }])
+    expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
+
+    ctx.tools.register(defineContentToolFixture({
+      name: 'read',
+      description: 'read a file',
+      parameters: {},
+      execute: () => Promise.resolve([]),
+    }))
+    expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
+    await fiber.dispose()
+    expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
+  })
+
+  it('invalidates cached searches after tool results and disposes them with the agent', async () => {
+    const ctx = await harness()
+    const { agent, dispose } = await stubAgent(ctx)
+    const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate')
+    const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose')
+    await ctx.plugin(LocalFileReferenceService)
+    await ctx.fileReferences.list(agent, 'README', new AbortController().signal)
+
+    ctx.emit('session/event', agent.session, { type: 'tool/result' } as never)
+    expect(invalidate).toHaveBeenCalledOnce()
+    ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never)
+    expect(invalidate).toHaveBeenCalledOnce()
+    const orphan = ctx.sessions.create(SessionId('file-reference-orphan'))
+    ctx.emit('session/event', orphan, { type: 'tool/result' } as never)
+    expect(invalidate).toHaveBeenCalledOnce()
+
+    dispose()
+    expect(close).toHaveBeenCalledOnce()
+    ctx.emit('agent/disposed', agent)
+  })
+
+  it('installs guidance for agents announced after the service and validates deployment tunables', async () => {
+    const ctx = await harness()
+    await ctx.plugin(LocalFileReferenceService)
+    const { agent } = await stubAgent(ctx)
+    await expect(ctx.fileReferences.list(agent, '', new AbortController().signal))
+      .resolves.toEqual([{ path: 'README.md', kind: 'file' }])
+
+    const badResults = await harness()
+    expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults')
+    const badEntries = await harness()
+    expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries')
+    const badExclusion = await harness()
+    expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] }))
+      .toThrow('excludedDirectories')
+    const fractionalResults = await harness()
+    expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults')
+    const zeroEntries = await harness()
+    expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries')
+    const emptyExclusion = await harness()
+    expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] }))
+      .toThrow('excludedDirectories')
+    const backslashExclusion = await harness()
+    expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] }))
+      .toThrow('excludedDirectories')
+  })
+
+  it('deduplicates lifecycle announcements and falls back to the process cwd', async () => {
+    const ctx = await harness()
+    const fiber = ctx.plugin(LocalFileReferenceService)
+    await fiber
+    const { agent } = await stubAgent(ctx, 'cwd-fallback', false)
+    ctx.emit('agent/created', agent)
+    const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([])
+    await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([])
+    await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([])
+    expect(list).toHaveBeenCalledTimes(2)
+  })
+
+  it('logs rejected prompt cleanup without failing service teardown', async () => {
+    const ctx = await harness()
+    const fiber = ctx.plugin(LocalFileReferenceService)
+    await fiber
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    const inject = vi.spyOn(ctx, 'inject')
+      .mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never)
+      // Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values.
+      // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
+      .mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never)
+    const first = await stubAgent(ctx, 'cleanup-one')
+    const second = await stubAgent(ctx, 'cleanup-two')
+    expect(inject).toHaveBeenCalledTimes(2)
+    first.dispose()
+    second.dispose()
+    await vi.waitFor(() => {
+      expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup')
+      expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup')
+    })
+    await expect(fiber.dispose()).resolves.toBeUndefined()
+  })
+})

+ 33 - 0
packages/context/file-reference-local/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../support/invariants"
+    },
+    {
+      "path": "../file-reference"
+    }
+  ]
+}

+ 6 - 0
packages/context/file-reference/README.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 packages/context/file-reference/README.md
+README.md: c65c787c2143ba88f2ac9887065b537c23f67ca2
+README.zh.md: 4c0d955bd6804f17ee99a5a65138f39391adfc95

+ 22 - 0
packages/context/file-reference/README.md

@@ -0,0 +1,22 @@
+# `@deepseek-ai/dsh-file-reference`
+
+English | [中文](README.zh.md)
+
+File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation.
+
+`activeAtToken()` recognizes an `@path` or open `@"path with spaces` token only at the start of input or after whitespace, so email-like text does not open completion. `formatFileMention()` emits the matching prompt spelling, appends `/` to directory candidates, preserves an explicitly opened quote, and rejects control characters or embedded quotes that the editor grammar cannot represent safely.
+
+Selecting a candidate does not read or attach file contents. The exported `FILE_REFERENCE_PROMPT` is stable guidance that a provider may install when the addressed agent can call `read`.
+
+## Model Experience
+
+Indirectly, through `@deepseek-ai/dsh-file-reference-local`, which conditionally contributes this package's stable file-reference guidance.
+
+#### KV Cache effect
+
+The interface and grammar add no request tokens themselves; a provider-owned prompt section determines cache behavior.
+
+## Known Limitations and Deferred Work
+
+- **Path candidates are advisory** — the seam does not prove that a later model-facing filesystem tool can access the same namespace; deployments must align the provider with the effective `read` implementation.
+- **No file-content reference object** — selected files remain ordinary prompt text and require an explicit model tool call before their contents become model-visible.

+ 22 - 0
packages/context/file-reference/README.zh.md

@@ -0,0 +1,22 @@
+# `@deepseek-ai/dsh-file-reference`
+
+[English](README.md) | 中文
+
+文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。
+
+`activeAtToken()` 只在输入开头或空白后识别 `@path` 或尚未闭合的 `@"path with spaces` token,因此类似电子邮件的文本不会打开补全。`formatFileMention()` 会生成与提示词匹配的写法,为目录候选追加 `/`,保留显式打开的引号,并拒绝编辑器语法无法安全表示的控制字符或内嵌引号。
+
+选择候选项不会读取或附加文件内容。导出的 `FILE_REFERENCE_PROMPT` 是稳定指引;当指定 agent 可以调用 `read` 时,提供方可以安装该指引。
+
+## 模型体验
+
+间接影响模型体验:`@deepseek-ai/dsh-file-reference-local` 会按条件贡献本包的稳定文件引用指引。
+
+#### KV 缓存影响
+
+接口和语法本身不会增加请求 token;缓存行为取决于提供方拥有的提示词段。
+
+## 已知限制与暂缓事项
+
+- **路径候选仅供参考**:该 seam 不保证后续面向模型的文件系统工具能够访问同一命名空间;部署时必须让提供方与实际生效的 `read` 实现对齐。
+- **没有文件内容引用对象**:所选文件仍是普通提示词文本,其内容必须经过模型显式调用工具后才对模型可见。

+ 44 - 0
packages/context/file-reference/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "@deepseek-ai/dsh-file-reference",
+  "description": "File-reference discovery contract and shared @file grammar",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./grammar": {
+      "types": "./lib/types/grammar.d.ts",
+      "default": "./lib/types/grammar.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 55 - 0
packages/context/file-reference/src/grammar.ts

@@ -0,0 +1,55 @@
+/**
+ * Browser-safe `@file` token grammar shared by terminal and web clients.
+ *
+ * @module @deepseek-ai/dsh-file-reference/grammar
+ */
+
+import type { FileReferenceCandidate } from './index.ts'
+
+/** Active `@` token ending at the editor cursor. */
+export interface ActiveAtToken {
+  /** Complete token replaced when the user accepts a completion. */
+  prefix: string
+  /** Path query after `@` or `@"`. */
+  query: string
+  /** Whether the user opened a quoted path. */
+  quoted: boolean
+}
+
+/**
+ * Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
+ * inside another token, such as an email address, is not a completion trigger.
+ * @param line - current editor line.
+ * @param cursorCol - cursor column within that line.
+ * @returns the active token, or `undefined` outside an `@` token.
+ */
+export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
+  const beforeCursor = line.slice(0, cursorCol)
+  const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
+  if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
+    return { prefix: quoted[1], query: quoted[2], quoted: true }
+  }
+  const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
+  if (plain?.[1] === undefined || plain[2] === undefined) return undefined
+  return { prefix: plain[1], query: plain[2], quoted: false }
+}
+
+/**
+ * Format a selected path as prompt text. Whitespace uses the quoted
+ * `@"path"` grammar; a quoted directory keeps that quote open after its
+ * trailing slash so completion can descend another level.
+ * @param candidate - selected file or directory.
+ * @param preserveQuote - retain an explicitly opened quote even when unnecessary.
+ * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
+ */
+export function formatFileMention(
+  candidate: FileReferenceCandidate,
+  preserveQuote: boolean,
+): string | undefined {
+  const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
+  if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
+  const quoted = preserveQuote || /\s/u.test(path)
+  if (!quoted) return `@${path}`
+  if (candidate.kind === 'directory') return `@"${path}`
+  return `@"${path}"`
+}

+ 51 - 0
packages/context/file-reference/src/index.ts

@@ -0,0 +1,51 @@
+/**
+ * File-reference discovery seam shared by host-backed user interfaces.
+ *
+ * @module @deepseek-ai/dsh-file-reference
+ */
+
+import { Service } from 'cordis'
+import type { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+
+export { activeAtToken, formatFileMention } from './grammar.ts'
+export type { ActiveAtToken } from './grammar.ts'
+
+/** Model guidance for path-only references selected by a user interface. */
+export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
+
+/** One path-only completion candidate inside the target session cwd. */
+export interface FileReferenceCandidate {
+  /** User-facing path accepted by normal prompts and filesystem tools. */
+  path: string
+  /** Directories keep completion open; files finish the mention. */
+  kind: 'file' | 'directory'
+}
+
+declare module 'cordis' {
+  interface Context {
+    fileReferences: FileReferenceService
+  }
+}
+
+/** Host capability for cancellable file-reference discovery. */
+export abstract class FileReferenceService extends Service {
+  constructor(ctx: Context) {
+    super(ctx, 'fileReferences')
+  }
+
+  /**
+   * List file and directory candidates for one agent's working directory.
+   * @param agent - target agent whose session cwd bounds discovery.
+   * @param query - path text following `@` or `@"`.
+   * @param signal - caller cancellation.
+   * @returns deterministic path-only candidates.
+   */
+  abstract list(
+    agent: Agent,
+    query: string,
+    signal: AbortSignal,
+  ): Promise<FileReferenceCandidate[]>
+}
+
+export default FileReferenceService

+ 30 - 0
packages/context/file-reference/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference`.
+ * @module @deepseek-ai/dsh-file-reference/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference'
+
+/** Cordis companion plugin name. */
+export const name = 'file-reference-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the interface retains no candidate or lifecycle
+ * state; concrete providers own their cache and invalidation relationships.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 12 - 0
packages/context/file-reference/tests/invariant.spec.ts

@@ -0,0 +1,12 @@
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as FileReferenceInvariant from '../src/invariant.ts'
+
+describe('invariant companion', () => {
+  it('registers the stateless seam under its package name', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantService, { enabled: true })
+    await expect(ctx.plugin(FileReferenceInvariant).await()).resolves.toBeDefined()
+  })
+})

+ 21 - 0
packages/context/file-reference/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 14 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -260,6 +260,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'fileReferences',
+    summary: 'Host capability for cancellable file-reference discovery.',
+    methods: [
+      {
+        signature: 'abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise<FileReferenceCandidate[]>',
+        jsDoc: '/**\n * List file and directory candidates for one agent\'s working directory.\n * @param agent - target agent whose session cwd bounds discovery.\n * @param query - path text following `@` or `@"`.\n * @param signal - caller cancellation.\n * @returns deterministic path-only candidates.\n */',
+      },
+    ],
+  },
   {
     key: 'fs',
     summary: 'Abstract filesystem provider.',
@@ -1603,6 +1613,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'FileLocation',
     declaration: 'export interface FileLocation {\n    path: string;\n    line?: number;\n}',
   },
+  {
+    name: 'FileReferenceCandidate',
+    declaration: 'export interface FileReferenceCandidate {\n    path: string;\n    kind: \'file\' | \'directory\';\n}',
+  },
   {
     name: 'FinishReason',
     declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];',

Някои файлове не бяха показани, защото твърде много файлове са промени