Browse Source

Merge remote-tracking branch 'origin/master' into feat/electron

# Conflicts:
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
07akioni 1 week ago
parent
commit
75c7bbb88d
100 changed files with 1807 additions and 347 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
  6. 2 2
      .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml
  8. 6 4
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
  9. 6 4
      .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md
  10. 0 46
      .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md
  11. 0 46
      .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md
  12. 3 3
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
  13. 14 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
  14. 14 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
  15. 7 0
      apps/cli/tests/web-agent-presets.e2e.ts
  16. 6 15
      apps/web/tests/agent-preset-authoring.e2e.ts
  17. 12 12
      apps/web/tests/command-image-envelope.expected.e2e.ts
  18. 9 0
      apps/web/tests/expected/file-upload-round/draft.expected.md
  19. 10 0
      apps/web/tests/expected/file-upload-round/history.expected.md
  20. 2 0
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  21. 2 0
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  22. 2 0
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  23. 2 0
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  24. 2 0
      apps/web/tests/expected/markdown-images/ui.expected.md
  25. 2 0
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  26. 2 0
      apps/web/tests/expected/math-rendering/ui.expected.md
  27. 2 0
      apps/web/tests/expected/reference-composer/order.expected.md
  28. 3 0
      apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md
  29. 2 0
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  30. 2 0
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  31. 2 0
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  32. 2 0
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  33. 2 0
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  34. 2 0
      apps/web/tests/expected/steer-all/settled.expected.md
  35. 283 0
      apps/web/tests/file-upload-round.e2e.ts
  36. 11 2
      apps/web/tests/goal-command-presentation.e2e.ts
  37. 12 16
      apps/web/tests/image-display.expected.e2e.ts
  38. 46 12
      apps/web/tests/scaffold.ts
  39. 11 2
      apps/web/tests/skill-invocation-policy.e2e.ts
  40. 4 0
      apps/web/tests/skill-user-invoke.e2e.ts
  41. 2 0
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  42. 2 2
      apps/web/tests/submission-echo.e2e.ts
  43. 1 0
      apps/web/tsconfig.json
  44. 2 2
      docs/capability-seams.i18n.yaml
  45. 5 0
      docs/capability-seams.md
  46. 5 0
      docs/capability-seams.zh.md
  47. 2 2
      docs/config-catalog.i18n.yaml
  48. 5 4
      docs/config-catalog.md
  49. 5 4
      docs/config-catalog.zh.md
  50. 2 2
      docs/event-producer-consumer.i18n.yaml
  51. 9 9
      docs/event-producer-consumer.md
  52. 8 8
      docs/event-producer-consumer.zh.md
  53. 2 2
      docs/module-graph.i18n.yaml
  54. 6 1
      docs/module-graph.md
  55. 6 1
      docs/module-graph.zh.md
  56. 2 2
      docs/persistence-catalog.i18n.yaml
  57. 2 2
      docs/persistence-catalog.md
  58. 2 2
      docs/persistence-catalog.zh.md
  59. 2 2
      docs/subsystems/attachment.i18n.yaml
  60. 157 4
      docs/subsystems/attachment.md
  61. 157 4
      docs/subsystems/attachment.zh.md
  62. 2 2
      docs/subsystems/commands.i18n.yaml
  63. 24 15
      docs/subsystems/commands.md
  64. 24 15
      docs/subsystems/commands.zh.md
  65. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  66. 12 1
      docs/subsystems/llm-streaming.md
  67. 12 1
      docs/subsystems/llm-streaming.zh.md
  68. 3 1
      packages/api/remotes/package.json
  69. 3 1
      packages/api/remotes/src/client/index.ts
  70. 2 0
      packages/api/remotes/tests/built-lib.e2e.ts
  71. 3 0
      packages/api/remotes/tsconfig.client.json
  72. 2 2
      packages/api/session-controller/README.i18n.yaml
  73. 2 2
      packages/api/session-controller/README.md
  74. 2 2
      packages/api/session-controller/README.zh.md
  75. 4 0
      packages/api/session-controller/package.json
  76. 9 6
      packages/api/session-controller/src/client/contract/session.ts
  77. 20 2
      packages/api/session-controller/src/client/contract/snapshot.ts
  78. 7 0
      packages/api/session-controller/src/client/index.ts
  79. 3 3
      packages/api/session-controller/src/client/sessions/queue-mirror.ts
  80. 2 2
      packages/api/session-controller/src/client/sessions/remotes.ts
  81. 26 12
      packages/api/session-controller/src/client/sessions/session.ts
  82. 61 3
      packages/api/session-controller/src/commands.ts
  83. 7 0
      packages/api/session-controller/src/index.ts
  84. 6 1
      packages/api/session-controller/src/types.ts
  85. 5 0
      packages/api/session-controller/tests/client-apply.client.spec.ts
  86. 3 2
      packages/api/session-controller/tests/client-contract.client.spec.ts
  87. 17 0
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  88. 471 0
      packages/api/session-controller/tests/commands-upload-file.host.spec.ts
  89. 26 1
      packages/api/session-controller/tests/controller.host.spec.ts
  90. 13 5
      packages/api/session-controller/tests/queue-store.client.spec.ts
  91. 13 4
      packages/api/session-controller/tests/session-cold.host.spec.ts
  92. 2 2
      packages/api/session-controller/tests/session-models.host.spec.ts
  93. 60 22
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  94. 23 1
      packages/api/session-controller/tests/session.client.spec.ts
  95. 37 0
      packages/api/session-controller/tests/test-remote.ts
  96. 1 0
      packages/api/session-controller/tsconfig.client.json
  97. 1 0
      packages/api/session-controller/tsconfig.host.json
  98. 2 2
      packages/attachment/attachment-local/README.i18n.yaml
  99. 6 3
      packages/attachment/attachment-local/README.md
  100. 6 3
      packages/attachment/attachment-local/README.zh.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c
+2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
 
 - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
 - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
-- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
-- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
+- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text.
+- Sending is the literal text (no more `<skill>` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape.
 - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
 
 ### Per-session provide contributions and the private keyboard surface

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
 
 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。
-- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
-- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)
+- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。
+- 发送即原文(不再 `<skill>` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰
 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
 
 ### 每会话供数贡献与键盘私面

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
-2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b
-2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99
+2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf
+2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md

@@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events,
 
 ## Decision
 
-`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
+`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal.
 
 `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism).
 
@@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
 
 **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
 
-**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
+**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly.
 
 **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md

@@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
 
 ## 决策
 
-`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
+`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。
 
 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。
 
@@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
 
 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。*
 
-**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
+**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。
 
 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
-2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238
-2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17
+2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76
+2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064

+ 6 - 4
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md

@@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md)
 
 ## Problem
 
-The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
+The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command.
 
 ## Decision
 
-The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
+The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking.
 
-The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token.
+The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token.
 
 ## Alternatives considered
 
@@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and
 
 **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product.
 
+**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason.
+
 ## Consequences
 
-Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged.
+Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged.

+ 6 - 4
.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md

@@ -6,13 +6,13 @@ Status: implemented
 
 ## 问题
 
-Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
+Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。
 
 ## 决策
 
-`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
+`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。
 
-评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。
+评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。
 
 ## 考虑过的替代方案
 
@@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字
 
 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。
 
+**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。
+
 ## 后果
 
-用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。
+用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。

+ 0 - 46
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md

@@ -1,46 +0,0 @@
-# Agent Note: Command image-attachment envelope
-
-Status: implemented
-
-English | [中文](2026-08-17-command-image-attachment-envelope.zh.md)
-
-## Problem
-
-The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest.
-
-Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork.
-
-## Decision
-
-The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly.
-
-**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot.
-
-**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics.
-
-**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
-
-**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan <message>` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit.
-
-**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
-
-## Testing
-
-Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes.
-
-## Alternatives considered
-
-- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all.
-- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule.
-- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility.
-- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors.
-- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself.
-- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`.
-
-## Consequences
-
-- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike.
-- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly.
-- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt.
-- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary.
-- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written".

+ 0 - 46
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md

@@ -1,46 +0,0 @@
-# Agent Note: Command image-attachment envelope
-
-Status: implemented
-
-[English](2026-08-17-command-image-attachment-envelope.md) | 中文
-
-## Problem
-
-Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。
-
-合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.zh.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。
-
-## Decision
-
-提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。
-
-**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。
-
-**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。
-
-**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
-
-**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan <message>` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。
-
-**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
-
-## Testing
-
-注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。
-
-## Alternatives considered
-
-- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。
-- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。
-- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。
-- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。
-- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。
-- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。
-
-## Consequences
-
-- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。
-- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。
-- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。
-- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。
-- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。

+ 3 - 3
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml → .agents/notes/implemented/feature/2026-08-26-generic-file-upload.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 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md
-2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9
-2026-08-17-command-image-attachment-envelope.zh.md: 9de197d06692523b7b5bca4f7aac36ce9f44b105
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
+2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402
+2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071

File diff suppressed because it is too large
+ 14 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md


File diff suppressed because it is too large
+ 14 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md


+ 7 - 0
apps/cli/tests/web-agent-presets.e2e.ts

@@ -82,6 +82,9 @@ async function bootWeb(
     // skills test below proves it reaches preset-composed agents.
     { id: 'skill-badge', disabled: false },
     { id: 'modules', disabled: true },
+    // The physical Connection row owns the disabled HTTP server. bootWeb
+    // supplies only its in-process registries so Host services still prove
+    // their shipped dependency graph without binding a port.
     { id: 'connection', disabled: true },
     // Export owns a Connection Fetch route, so this Host-only composition
     // disables it with the transport service above.
@@ -139,6 +142,10 @@ async function bootWeb(
   const rootConfig = join(profileDir, 'cordis.yml')
   await writeFile(rootConfig, '[]\n')
   return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => {
+    bootCtx.provide('connection', {
+      fetch: { register: () => () => {} },
+      rpc: { intercept: () => () => {} },
+    } as never)
     provideCmdline(bootCtx, { args: [], exit: () => {} })
   })
 }

+ 6 - 15
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -44,17 +44,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     return page.getByRole('dialog', { name: '设置' })
   }
 
-  /** Tokenize the lane-owned preset root after general aria normalization. */
-  function withPresetRoot(snapshot: string): string {
-    const rootSuffix = `/${userRoot.split('/').pop()!}`
-    return snapshot.split('\n').map((line) => {
-      const rootStart = line.indexOf(rootSuffix)
-      if (rootStart === -1) return line
-      const pathStart = line.lastIndexOf(' ', rootStart) + 1
-      return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
-    }).join('\n')
-  }
-
   beforeAll(async () => {
     userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
     scaffold = await launchWebScaffold({
@@ -147,8 +136,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     // The copy dialog is detached, so the settings dialog is the only one
     // left (it names itself via aria-labelledby, which a CSS attribute
     // selector cannot address).
-    const snapshot = withPresetRoot(
-      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, {
+      replacements: [[userRoot, '{{presetRoot}}']],
+    })
     await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
     expect(snapshot).toContain('{{presetRoot}}/my-agent')
 
@@ -197,8 +187,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     await dialog.getByRole('button', { name: 'Agent 预设' }).click()
     await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
 
-    const snapshot = withPresetRoot(
-      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, {
+      replacements: [[userRoot, '{{presetRoot}}']],
+    })
     await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
     // Both damage shapes surface as marked, unselectable, uncopyable cards
     // that still carry their metadata and the discovery-reported reason.

+ 12 - 12
apps/web/tests/command-image-envelope.expected.e2e.ts

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
-// The command image-attachment envelope over the BUILT client graph (real
+// The command attachment envelope over the BUILT client graph (real
 // bundles via AppWebEntry, keyless fixture Connection RPC): an enter
-// submission carrying composer images resolves only through a command whose
-// descriptor declares `input.images`. A non-declaring command refuses with
+// submission carrying composer attachments resolves only through a command whose
+// descriptor declares `input.attachments`. A non-declaring command refuses with
 // one composer error banner and everything retained; a declaring command
 // consumes the images — serialized through the real draft-image chain into
 // the commands/execute payload — and clears the composer on success, including
@@ -47,7 +47,7 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise<void> {
     },
   })
   await waitFor(() => {
-    const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (rail === null) throw new Error('attachment rail missing')
     expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
   }, { timeout: 5_000 })
@@ -58,7 +58,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   const textarea = await freshComposer()
   await pasteImage(textarea, 'ref.png')
 
-  // /echo is a leadingInput fixture command without `input.images`.
+  // /echo is a leadingInput fixture command without `input.attachments`.
   await pasteText(textarea, '/echo hello')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
@@ -66,16 +66,16 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   // failures; session activity remains on its separate status live region.
   const notice = await waitFor(() => {
     const el = [...document.querySelectorAll('[role="alert"]')]
-      .find(candidate => candidate.textContent?.includes('image attachments') ?? false)
+      .find(candidate => candidate.textContent?.includes('attachments') ?? false)
     if (el === undefined) throw new Error('composer refusal banner missing')
     return el
   }, { timeout: 5_000 })
-  expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
+  expect(notice.textContent).toBe('/echo does not accept attachments; remove them first')
   expect([...document.querySelectorAll('[role="status"]')]
-    .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
+    .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false)
   // The whole envelope is retained: draft text and the rail thumbnail.
   await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') })
-  const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+  const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
   expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
 })
 
@@ -84,14 +84,14 @@ it('consumes images through a declaring command and clears the composer on succe
   const textarea = await freshComposer()
   await pasteImage(textarea, 'goal-ref.png')
 
-  // /goal declares `input.images` in the fixture catalog; the claim submit
+  // /goal declares `input.attachments` in the fixture catalog; the claim submit
   // serializes the pasted bytes and the fixture executor admits them.
   await pasteText(textarea, '/goal rebuild the cathedral')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
   await waitFor(() => {
     expect(textarea.textContent).toBe('')
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   }, { timeout: 5_000 })
 })
 
@@ -107,7 +107,7 @@ it('submits a bare /plan with an image as an image-only plan request', async ()
 
   await waitFor(() => {
     expect(textarea.textContent).toBe('')
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   }, { timeout: 5_000 })
   expect([...document.querySelectorAll('[role="alert"]')]
     .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)

+ 9 - 0
apps/web/tests/expected/file-upload-round/draft.expected.md

@@ -0,0 +1,9 @@
+# Mixed composer attachment rail
+
+- selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png
+- one attachment group: true
+- all cards share one row: true
+- every card is 64px high: true
+- the file card is wider than an image: true
+- overflowing cards scroll horizontally: true
+- the rail does not wrap: true

+ 10 - 0
apps/web/tests/expected/file-upload-round/history.expected.md

@@ -0,0 +1,10 @@
+# Mixed history attachment flow
+
+- source order: poem.txt > reference-1.png
+- one attachment group: true
+- file and image share one row: true
+- both cards are 64px high: true
+- the image is a 64px tile: true
+- the file card is wider than the image: true
+- the group wraps when needed: true
+- the group is right-aligned: true

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
   - text: github-webhook-review-test/reply

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -43,6 +43,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
   - text: github-webhook-review-test/reply

+ 2 - 0
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -17,6 +17,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -30,6 +30,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -42,6 +42,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -46,6 +46,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/reference-composer/order.expected.md

@@ -16,6 +16,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

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

@@ -0,0 +1,3 @@
+- listbox "Trigger suggestions":
+  - text: Skills
+  - option "policy-user-only user-only · Available only to user invocation" [selected]

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

@@ -43,6 +43,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

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

@@ -35,6 +35,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -462,6 +462,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -34,6 +34,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -53,6 +53,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/settled.expected.md

@@ -41,6 +41,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 283 - 0
apps/web/tests/file-upload-round.e2e.ts

@@ -0,0 +1,283 @@
+// Web e2e scenario: generic file upload round trip. A real chromium picks a
+// file through the composer paperclip input; the upload RPC stores the exact
+// bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged
+// reference, request assembly projects the file block to handle text, and the
+// model (replayed or live) reads the saved copy with the REAL read tool. The
+// content-addressed store makes the saved path identical across record and
+// replay once the workspace cwd is tokenized, so the recorded read arguments
+// replay verbatim against a freshly re-uploaded object.
+// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless
+// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
+const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
+const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url))
+const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url))
+const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
+const MODE = webSnapshotMode()
+
+/** The uploaded fixture file: constant bytes so record and replay share one content digest. */
+const FILE_NAME = 'poem.txt'
+const FILE_TEXT = 'UPLOAD_ROUND_OK\n'
+const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.'
+const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`)
+
+/** Browser-measured relations for the mixed composer attachment rail. */
+interface DraftRailGeometry {
+  readonly order: readonly string[]
+  readonly oneGroup: boolean
+  readonly oneRow: boolean
+  readonly equalHeight: boolean
+  readonly fileWider: boolean
+  readonly horizontalOverflow: boolean
+  readonly noWrap: boolean
+}
+
+/** Render stable relations instead of platform-dependent absolute coordinates. */
+function renderDraftRailGeometry(geometry: DraftRailGeometry): string {
+  return [
+    '# Mixed composer attachment rail',
+    '',
+    `- selection order: ${geometry.order.join(' > ')}`,
+    `- one attachment group: ${String(geometry.oneGroup)}`,
+    `- all cards share one row: ${String(geometry.oneRow)}`,
+    `- every card is 64px high: ${String(geometry.equalHeight)}`,
+    `- the file card is wider than an image: ${String(geometry.fileWider)}`,
+    `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`,
+    `- the rail does not wrap: ${String(geometry.noWrap)}`,
+  ].join('\n')
+}
+
+/** Browser-measured relations for one durable mixed-attachment message. */
+interface HistoryAttachmentGeometry {
+  readonly order: readonly string[]
+  readonly oneGroup: boolean
+  readonly oneRow: boolean
+  readonly equalHeight: boolean
+  readonly imageIsTile: boolean
+  readonly fileWider: boolean
+  readonly wrapsWhenNeeded: boolean
+  readonly rightAligned: boolean
+}
+
+/** Render stable history-layout relations instead of absolute coordinates. */
+function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string {
+  return [
+    '# Mixed history attachment flow',
+    '',
+    `- source order: ${geometry.order.join(' > ')}`,
+    `- one attachment group: ${String(geometry.oneGroup)}`,
+    `- file and image share one row: ${String(geometry.oneRow)}`,
+    `- both cards are 64px high: ${String(geometry.equalHeight)}`,
+    `- the image is a 64px tile: ${String(geometry.imageIsTile)}`,
+    `- the file card is wider than the image: ${String(geometry.fileWider)}`,
+    `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`,
+    `- the group is right-aligned: ${String(geometry.rightAligned)}`,
+  ].join('\n')
+}
+
+describe('web e2e: generic file upload through the real assembly', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  const sessionEvents: SessionEvent[] = []
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      // The override rescripts the recorded read arguments with a
+      // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run,
+      // and the live handle line in the request carries the current one.
+      ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }),
+    })
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.setViewportSize({ width: 900, height: 900 })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive'))
+    if (MODE !== 'record') {
+      // Drift guard: the committed fixture must carry exactly the drive prompt.
+      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    }
+    const input = page.locator('[data-composer-input]').first()
+    await input.waitFor({ timeout: 10_000 })
+    const modelTrigger = page.getByRole('button', { name: /^Select model, current/ })
+    await modelTrigger.click()
+    await page.getByRole('menuitem', { name: /^Model\b/ }).click()
+    await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click()
+    await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 })
+      .toContain('DeepSeek-V4-Flash-Vision-Exp')
+    const imageBytes = await readFile(IMAGE_FIXTURE)
+    // Pick through the composer's hidden file input: the upload RPC runs
+    // immediately and the pending card appears before any prompt is typed.
+    await page.locator('input[type="file"]').setInputFiles([
+      { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) },
+      ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })),
+    ])
+    await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 })
+    const rail = page.getByRole('group', { name: 'Pending attachments' })
+    await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 })
+      .toBe(IMAGE_NAMES.length + 1)
+    const geometry = await rail.evaluate((element): DraftRailGeometry => {
+      const cards = [...element.children] as HTMLElement[]
+      const boxes = cards.map(card => card.getBoundingClientRect())
+      const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0
+      const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0
+      return {
+        order: cards.map(card => card.querySelector('img')?.getAttribute('alt')
+          ?? card.querySelector<HTMLElement>('[title]')?.title ?? ''),
+        oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1,
+        oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
+        equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
+        fileWider: fileWidth > imageWidth,
+        horizontalOverflow: element.scrollWidth > element.clientWidth,
+        noWrap: getComputedStyle(element).flexWrap === 'nowrap',
+      }
+    })
+    await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE)
+    for (const name of IMAGE_NAMES.slice(1)) {
+      await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true })
+    }
+    await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2)
+    await input.fill(PROMPT)
+    // Send unlocks only after the upload receipt lands (the staged file gate).
+    const send = page.getByRole('button', { name: 'Send message' })
+    await send.waitFor({ state: 'visible', timeout: 15_000 })
+    await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
+    const settled = scaffold.whenTurnSettled()
+    await send.click()
+    const restoreDeadline = Date.now() + 10_000
+    let retainedCards = 0
+    while (retainedCards === 0
+      && !sessionEvents.some(event => event.type === 'turn/start')
+      && Date.now() < restoreDeadline) {
+      await page.waitForTimeout(100)
+      retainedCards = await rail.locator(':scope > *').count()
+    }
+    if (retainedCards !== 0) {
+      const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents()
+      throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`)
+    }
+    const sessionId = await settled
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+  }, 200_000)
+
+  it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => {
+    const userMessage = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'user/message' }> =>
+        event.type === 'user/message' && event.data.source.kind === 'user',
+    )
+    if (userMessage === undefined) throw new Error('the replayed turn recorded no user message')
+    const fileBlock = userMessage.data.content.find(block => block.type === 'file')
+    if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block')
+    expect(fileBlock.attachment.name).toBe(FILE_NAME)
+    expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT))
+    expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/)
+
+    const readCall = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
+        event.type === 'tool/call' && event.data.name === 'read',
+    )
+    if (readCall === undefined) throw new Error('the replayed turn did not call the read tool')
+    expect(readCall.data.arguments).toContain(FILE_NAME)
+    const readResult = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
+        event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId,
+    )
+    if (readResult === undefined) throw new Error('the read call produced no durable result')
+    const content = readResult.data.message.content[0]
+    expect(content.isError).toBe(false)
+    expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
+      .toContain('UPLOAD_ROUND_OK')
+
+    const turnEnds = sessionEvents.filter(event => event.type === 'turn/end')
+    expect(turnEnds.length).toBe(1)
+    expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+  })
+
+  it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria'))
+    await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 })
+      .toBeGreaterThanOrEqual(1)
+    await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => {
+    const groups = page.locator('[data-message-attachments]')
+    await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1)
+    const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => {
+      const cards = [...element.children] as HTMLElement[]
+      const renderedCards = cards.map((card) => {
+        const box = card.getBoundingClientRect()
+        return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement
+          ? card.firstElementChild
+          : card
+      })
+      const boxes = renderedCards.map(card => card.getBoundingClientRect())
+      const imageIndex = cards.findIndex(card => card.querySelector('img') !== null)
+      const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt')
+      const imageBox = boxes[imageIndex]
+      const fileBox = boxes[fileIndex]
+      return {
+        order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''),
+        oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1,
+        oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
+        equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
+        imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5,
+        fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width,
+        wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap',
+        rightAligned: getComputedStyle(element).justifyContent === 'flex-end',
+      }
+    })
+    await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE)
+  })
+
+  it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => {
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+    await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(
+      page,
+      '[data-trajectory-row-key][aria-label*="Files ×1"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
+    ])
+  })
+})

+ 11 - 2
apps/web/tests/goal-command-presentation.e2e.ts

@@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => {
     const typography = await commandInput.evaluate((element) => {
       const bubble = element.firstElementChild?.firstElementChild
       if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
+      const chip = bubble.querySelector('[data-ref-chip="command"]')
+      if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing')
       const rootStyle = getComputedStyle(element)
       const bubbleStyle = getComputedStyle(bubble)
+      const chipStyle = getComputedStyle(chip)
       return {
         fontFamily: bubbleStyle.fontFamily,
         parentFontFamily: rootStyle.fontFamily,
         fontSize: bubbleStyle.fontSize,
         lineHeight: bubbleStyle.lineHeight,
+        chipText: chip.textContent,
+        chipFontFamily: chipStyle.fontFamily,
+        chipFontSize: chipStyle.fontSize,
       }
     })
-    expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
-    expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
+    expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' })
+    // The bubble reads in the body face like a user bubble; only the command
+    // chip carries the code face that marks the echoed token as a command.
+    expect(typography.fontFamily).toBe(typography.parentFontFamily)
+    expect(typography.chipFontFamily).not.toBe(typography.fontFamily)
     const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
     await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
     expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)

+ 12 - 16
apps/web/tests/image-display.expected.e2e.ts

@@ -106,7 +106,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   // The rail is an accessible group holding the draft thumbnail (queried via
   // DOM: jsdom's a11y-visibility computation hides the composer subtree).
   const rail = await waitFor(() => {
-    const el = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const el = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (el === null) throw new Error('attachment rail missing')
     return el
   }, { timeout: 5_000 })
@@ -137,23 +137,19 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   if (remove.length !== 2) throw new Error('remove buttons missing')
   for (const button of remove) fireEvent.click(button)
   await waitFor(() => {
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   })
 
-  // An unsupported file announces a transient toast (the inline strip is
-  // gone) and the banner dismisses itself after its hold-and-fade lifetime.
+  // A non-image paste follows the generic-file path and remains in the
+  // composer as a file card.
   fireEvent.paste(textarea, {
     clipboardData: {
       items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
       getData: () => '',
     },
   })
-  const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported'
-  const toast = await screen.findByText(unsupportedMessage)
-  expect(toast.closest('[role="alert"]')).not.toBeNull()
-  await waitFor(() => {
-    expect(screen.queryByText(unsupportedMessage)).toBeNull()
-  }, { timeout: 6_000 })
+  const files = await screen.findByRole('group', { name: 'Pending attachments' })
+  expect(files.textContent).toContain('notes.txt')
 })
 
 it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => {
@@ -178,15 +174,15 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
   fireEvent.dragEnter(document.body, { dataTransfer })
   const overlay = await screen.findByRole('status')
-  expect(overlay.textContent).toContain('Drag images here to add them')
+  expect(overlay.textContent).toContain('Drag files or images here to add them')
   await waitFor(() => {
-    expect(overlay.textContent).toContain('Up to 20 images, 5MB each')
+    expect(overlay.textContent).toContain('Image limit: up to 20 images, 5MB each')
   })
 
   // Dropping on the transcript area (not the composer card) lands in the rail.
   fireEvent.drop(document.body, { dataTransfer })
   await waitFor(() => {
-    const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (rail === null) throw new Error('attachment rail missing after page drop')
     expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png'])
   }, { timeout: 5_000 })
@@ -206,7 +202,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const limitMessage = 'A message can include up to 20 images'
   const banner = await screen.findByText(limitMessage)
   expect(banner.closest('[role="alert"]')).not.toBeNull()
-  const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+  const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
   expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
 })
 
@@ -233,7 +229,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
     },
   })
   await waitFor(() => {
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull()
   })
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
@@ -245,5 +241,5 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
       "text": "Image sides must be at most 2000px; downscale it and try again",
     }
   `)
-  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+  expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull()
 })

+ 46 - 12
apps/web/tests/scaffold.ts

@@ -192,7 +192,18 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
 const REPLAY_PROVIDERS = [{
   id: 'deepseek-official',
   name: 'DeepSeek',
-  models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
+  models: [
+    { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 },
+    {
+      id: 'deepseek-v4-flash-vision-exp',
+      name: 'DeepSeek-V4-Flash-Vision-Exp',
+      contextWindow: 1_000_000,
+      inputModalities: ['text', 'image'] as const,
+      defaultMaxTokens: 256_000,
+      reasoningEfforts: ['off', 'low', 'high', 'max'],
+      defaultReasoningEffort: 'high',
+    },
+  ],
 }]
 
 /**
@@ -819,6 +830,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
             replayFixture,
             mode,
             `http://${browserHost}:${port}`,
+            harnessHome,
           )
         } catch (error) {
           failures.push(error)
@@ -953,6 +965,7 @@ function stableSessionFixture(
   session: Session,
   existing: string,
   workspaceCwd: string,
+  harnessHome: string,
 ): string {
   const prepared = prepareSessionSnapshotFixtureForComparison(
     normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd),
@@ -965,6 +978,7 @@ function stableSessionFixture(
     })
   const fresh = scrubSessionSnapshot(stabilized)
     .split(session.id).join('{{sessionId}}')
+    .split(harnessHome).join('{{harnessHome}}')
   const stable = redactSessionSnapshotIds(stabilizeFixtureMessageIds([fresh], [existing]))[0]
   if (stable === undefined) throw new Error('session harvest produced no stabilized fixture')
   return stable
@@ -975,6 +989,7 @@ async function assertReplaySession(
   fixturePath: string,
   mode: WebSnapshotMode,
   webUrl: string,
+  harnessHome: string,
 ): Promise<void> {
   let expected = await readFile(fixturePath, 'utf8')
   const fixtureDir = dirname(fixturePath)
@@ -997,7 +1012,7 @@ async function assertReplaySession(
   if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`)
   const actual = rawSessionLog(session)
   if (mode === 'refresh' && writesCurrentSessionFixtures(manifest, mode)) {
-    expected = stableSessionFixture(session, expected, sessionCwd)
+    expected = stableSessionFixture(session, expected, sessionCwd, harnessHome)
     expectedPath = recordedSessionFixturePath(fixturePath, session.header.version)
     await writeFile(expectedPath, expected)
   }
@@ -1010,8 +1025,11 @@ async function assertReplaySession(
     sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [],
     cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0',
   }
-  expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`)
-    .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0])
+  const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0]
+    ?.split(harnessHome).join('{{harnessHome}}')
+  const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]
+    ?.split(harnessHome).join('{{harnessHome}}')
+  expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot)
 
   if (manifest.header?.pin !== true) return
   const normalizePrompt = (value: string): string => value
@@ -1032,7 +1050,7 @@ async function assertReplaySession(
 
 /**
  * Record-mode fixture write-back: harvest the live session, scrub request
- * headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque
+ * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, redact opaque
  * identities with typed relationship-preserving tokens, and write the fixture.
  * A manifest-retained historical generation makes the write-back a no-op.
  * @param scaffold - the record-mode scaffold.
@@ -1048,7 +1066,12 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId,
   const target = recordedSessionFixturePath(fixturePath, agent.session.header.version)
   const existingPath = existsSync(target) ? target : fixturePath
   const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : ''
-  await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd))
+  await writeFile(target, stableSessionFixture(
+    agent.session,
+    existing,
+    scaffold.workspaceCwd,
+    scaffold.harnessHome,
+  ))
 }
 
 /**
@@ -1078,8 +1101,8 @@ export function fixtureIdentity(
 
 /**
  * Realize a recorded seed fixture against one scaffold: substitute the
- * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the
- * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
+ * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the
+ * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
  * price content exactly as the host will fold it) and still pass the result
  * through {@link seedSession}.
  * @param scaffold - the booted scaffold whose workspace the seed targets.
@@ -1104,6 +1127,7 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
         .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`)
         .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) =>
           fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal)))
+        .split('{{harnessHome}}').join(scaffold.harnessHome)
         .split('{{cwd}}').join(scaffold.workspaceCwd)
       return result
     })
@@ -1373,20 +1397,30 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st
  * @param selector - the region locator selector.
  * @param workspaceCwd - normalization input.
  * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}`
- *   for a region whose rows are dated from live wall-clock state.
+ *   for a region whose rows are dated from live wall-clock state;
+ *   `replacements` tokenizes scenario-owned values before generic normalization.
  * @returns the stable normalized snapshot.
  */
 export async function captureStableAria(
   page: Page,
   selector: string,
   workspaceCwd: string,
-  options: { normalizeAge?: boolean } = {},
+  options: {
+    normalizeAge?: boolean
+    replacements?: readonly (readonly [value: string, token: string])[]
+  } = {},
 ): Promise<string> {
   const region = page.locator(selector).first()
   const age = options.normalizeAge === true
-  let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age)
+  const normalize = (snapshot: string): string => {
+    for (const [value, token] of options.replacements ?? []) {
+      snapshot = snapshot.split(value).join(token)
+    }
+    return normalizeAria(snapshot, workspaceCwd, age)
+  }
+  let previous = normalize(await region.ariaSnapshot())
   await expect.poll(async () => {
-    const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age)
+    const current = normalize(await region.ariaSnapshot())
     const stable = current === previous
     previous = current
     return stable

+ 11 - 2
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -18,10 +18,11 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url))
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
+const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md')
 const MODE = webSnapshotMode()
 
 interface SeedSkill {
@@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => {
 
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
+
+    // Discovery needs no prefix: an in-order subsequence of one skill name
+    // ranks that skill alone, through the ranker the command group uses.
+    await writeComposerDraft(page, input, '/plcyusr')
+    await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1)
+    expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1)
+    const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md'])
   })
 })

+ 4 - 0
apps/web/tests/skill-user-invoke.e2e.ts

@@ -130,6 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     await settled
     const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
     await process.waitFor({ state: 'visible', timeout: 10_000 })
+    // The chip derives from the step's logged injection, so it must survive
+    // every later Node rebuild of the Turn (process publication, turn close).
+    expect(await bubble.count()).toBe(1)
+    expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
     await expandOwningTurnProcess(page, injectionFlow)
     const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
     await injectionRow.click()

+ 2 - 0
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -27,6 +27,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current streaming-fence-highlight-test/streaming-fence":
   - text: streaming-fence-highlight-test/streaming-fence

+ 2 - 2
apps/web/tests/submission-echo.e2e.ts

@@ -33,7 +33,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
     },
   })
   await waitFor(() => {
-    if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) {
+    if (document.querySelector('[role="group"][aria-label="Pending attachments"] img') === null) {
       throw new Error('attachment rail missing')
     }
   }, { timeout: 5_000 })
@@ -53,7 +53,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
   expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob')
   expect(composer.textContent).toBe('')
   expect(composer.getAttribute('contenteditable')).toBe('true')
-  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+  expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
 
   // The fixture's durable user/message (source.rpcId echoes the prompt
   // requestId) replaces the echo: one bubble, no marker left, and the image

+ 1 - 0
apps/web/tsconfig.json

@@ -63,6 +63,7 @@
     "tests/composer-draft-scroll.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
+    "tests/file-upload-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/message-feedback.e2e.ts",
     "tests/message-feedback-layout.e2e.ts",

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 83868afe952c5dbc179114ff52228fc1dc8b8fdb
-capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029
+capability-seams.md: ecc88e397f41c4d279effa2df9d1b29108756ea5
+capability-seams.zh.md: 105752ac01007d24788bcfbcb2eb22c07cfdd0f5

+ 5 - 0
docs/capability-seams.md

@@ -14,6 +14,8 @@ flowchart LR
   pkg_tool_fs["tool-fs"]
   pkg_llm_pi_ai["llm-pi-ai"]
   pkg_llm_deepseek["llm-deepseek"]
+  pkg_client_file_upload["client-file-upload"]
+  svc_fileUploads["ctx.fileUploads<br/>Agent-scoped staged file uploads"]
   pkg_llm["llm"]
   svc_llm["ctx.llm<br/>LLM adapter registry"]
   pkg_llm_replay["llm-replay"]
@@ -235,6 +237,7 @@ flowchart LR
   pkg_authorization --> svc_authorization
   pkg_bash_local --> svc_shell
   pkg_bash_sandbox --> svc_shell
+  pkg_client_file_upload --> svc_fileUploads
   pkg_client_modules --> svc_clientModules
   pkg_code_runtime --> svc_codeRuntime
   pkg_code_runtime_worker_thread --> svc_codeRuntime
@@ -364,6 +367,7 @@ flowchart LR
   svc_e2b --> pkg_fs_e2b
   svc_e2b --> pkg_subprocess_e2b
   svc_fileReferences --> pkg_api_session_controller
+  svc_fileUploads --> pkg_api_session_controller
   svc_fs --> pkg_tool_fs
   svc_invariants --> pkg_agent
   svc_invariants --> pkg_agent_loop
@@ -466,6 +470,7 @@ flowchart LR
 | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
 | --- | --- | --- | --- | --- | --- | --- |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
+| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
 | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. |
 | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |

+ 5 - 0
docs/capability-seams.zh.md

@@ -16,6 +16,8 @@ flowchart LR
   pkg_tool_fs["tool-fs"]
   pkg_llm_pi_ai["llm-pi-ai"]
   pkg_llm_deepseek["llm-deepseek"]
+  pkg_client_file_upload["client-file-upload"]
+  svc_fileUploads["ctx.fileUploads<br/>Agent-scoped staged file uploads"]
   pkg_llm["llm"]
   svc_llm["ctx.llm<br/>LLM adapter registry"]
   pkg_llm_replay["llm-replay"]
@@ -237,6 +239,7 @@ flowchart LR
   pkg_authorization --> svc_authorization
   pkg_bash_local --> svc_shell
   pkg_bash_sandbox --> svc_shell
+  pkg_client_file_upload --> svc_fileUploads
   pkg_client_modules --> svc_clientModules
   pkg_code_runtime --> svc_codeRuntime
   pkg_code_runtime_worker_thread --> svc_codeRuntime
@@ -366,6 +369,7 @@ flowchart LR
   svc_e2b --> pkg_fs_e2b
   svc_e2b --> pkg_subprocess_e2b
   svc_fileReferences --> pkg_api_session_controller
+  svc_fileUploads --> pkg_api_session_controller
   svc_fs --> pkg_tool_fs
   svc_invariants --> pkg_agent
   svc_invariants --> pkg_agent_loop
@@ -468,6 +472,7 @@ flowchart LR
 | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
 | --- | --- | --- | --- | --- | --- | --- |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
+| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |
 | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 |
 | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 136e42c22f58f4e7102f7fb3b905ab956db55ecd
-config-catalog.zh.md: 88a6bca44c8e8b396cb8efaf4be5cd704a6518f8
+config-catalog.md: a0fea049bf6e15ef8c342a329b3a55208e8e29ee
+config-catalog.zh.md: ba8fbba2cabe52b150e90110bd150bb9f7f13a5b

+ 5 - 4
docs/config-catalog.md

@@ -203,7 +203,7 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in
 
 ## `@deepseek-ai/dsh-api-session-controller`
 
-Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
+Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
 
 ```ts config-catalog
 /** Session Controller deployment policy. */
@@ -213,7 +213,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts)
+Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-settings-controller"></a>
 
@@ -262,7 +262,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts)
 
 <a id="deepseek-aidsh-bash-local"></a>
 
@@ -336,7 +336,7 @@ export interface ConnectionConfig {
 }
 ```
 
-Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts)
+Source: [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts)
 
 <a id="deepseek-aidsh-client-hmr"></a>
 
@@ -3325,6 +3325,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
 - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
 - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
+- `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts))
 - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — requires `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))

+ 5 - 4
docs/config-catalog.zh.md

@@ -205,7 +205,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-api-session-controller`
 
-需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
+需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
 
 ```ts config-catalog
 /** Session Controller deployment policy. */
@@ -215,7 +215,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts)
+来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-settings-controller"></a>
 
@@ -264,7 +264,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts)
+来源:[`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts)
 
 <a id="deepseek-aidsh-bash-local"></a>
 
@@ -338,7 +338,7 @@ export interface ConnectionConfig {
 }
 ```
 
-来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts)
+来源:[`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts)
 
 <a id="deepseek-aidsh-client-hmr"></a>
 
@@ -3327,6 +3327,7 @@ export interface Config {
 - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
 - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
 - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
+- `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts))
 - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — 需要 `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 62eba828604ba1e90d9d7ef22743cf10daf84f70
-event-producer-consumer.zh.md: f82c379516d9aec00dade2acdf3f8291a340da44
+event-producer-consumer.md: 1d643f14cabd361e82468cbcec1e2d418e4f079c
+event-producer-consumer.zh.md: 80857c699e1a2b2e0956f909c26657754b5d909f

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

@@ -22,14 +22,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
@@ -44,11 +44,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |

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

@@ -24,11 +24,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -46,11 +46,11 @@
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 6a5e4232f5a4da309b48c2e7c00dadf09e8b308a
-module-graph.zh.md: 2388a1d9843e5e2fe308e372f9d7db8150b48160
+module-graph.md: be0bc1db1222ba7b95dff735a339bef680b86cf5
+module-graph.zh.md: e1fdaae73ac398076598316efcfa45822e378155

+ 6 - 1
docs/module-graph.md

@@ -132,6 +132,7 @@ flowchart TD
   end
   subgraph group_client["packages/client"]
     pkg_client_connection["client-connection"]
+    pkg_client_file_upload["client-file-upload"]
     pkg_client_hmr["client-hmr"]
     pkg_client_locale["client-locale"]
     pkg_client_modules["client-modules"]
@@ -407,6 +408,7 @@ flowchart TD
   pkg_api_remotes --> pkg_scope
   pkg_attachment_local --> pkg_attachment
   pkg_attachment_local --> pkg_home_paths
+  pkg_client_file_upload --> pkg_scope
   pkg_authorization --> pkg_credentials
   pkg_authorization --> pkg_invariants
   pkg_authorization --> pkg_llm
@@ -1052,6 +1054,8 @@ flowchart TD
   pkg_api_session_controller --> pkg_api_gateway
   pkg_api_session_controller --> pkg_attachment
   pkg_api_session_controller --> pkg_client_connection
+  pkg_api_session_controller --> pkg_client_file_upload
+  pkg_api_session_controller --> pkg_commands
   pkg_api_session_controller --> pkg_file_reference
   pkg_api_session_controller --> pkg_jobs
   pkg_api_session_controller --> pkg_llm
@@ -1261,6 +1265,7 @@ flowchart TD
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
+| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
 | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
 | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1391,7 +1396,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 6 - 1
docs/module-graph.zh.md

@@ -134,6 +134,7 @@ flowchart TD
   end
   subgraph group_client["packages/client"]
     pkg_client_connection["client-connection"]
+    pkg_client_file_upload["client-file-upload"]
     pkg_client_hmr["client-hmr"]
     pkg_client_locale["client-locale"]
     pkg_client_modules["client-modules"]
@@ -409,6 +410,7 @@ flowchart TD
   pkg_api_remotes --> pkg_scope
   pkg_attachment_local --> pkg_attachment
   pkg_attachment_local --> pkg_home_paths
+  pkg_client_file_upload --> pkg_scope
   pkg_authorization --> pkg_credentials
   pkg_authorization --> pkg_invariants
   pkg_authorization --> pkg_llm
@@ -1054,6 +1056,8 @@ flowchart TD
   pkg_api_session_controller --> pkg_api_gateway
   pkg_api_session_controller --> pkg_attachment
   pkg_api_session_controller --> pkg_client_connection
+  pkg_api_session_controller --> pkg_client_file_upload
+  pkg_api_session_controller --> pkg_commands
   pkg_api_session_controller --> pkg_file_reference
   pkg_api_session_controller --> pkg_jobs
   pkg_api_session_controller --> pkg_llm
@@ -1263,6 +1267,7 @@ flowchart TD
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
+| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
 | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
 | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1393,7 +1398,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 76d91ea8e8edb625f9733b6ad28d225d2ee059a8
-persistence-catalog.zh.md: 52625069c16094e1b056b9ee9013409fd1690705
+persistence-catalog.md: 0b52801083498542c814d06171942f33d013efe2
+persistence-catalog.zh.md: eda29e00c9dfaff6c5d35afba55f2a8cfb95f075

+ 2 - 2
docs/persistence-catalog.md

@@ -268,7 +268,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/
 }
 ```
 
-Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts)
+Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts)
 
 <a id="commandrun--log-only"></a>
 
@@ -288,7 +288,7 @@ Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interacti
 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
 ```
 
-Source: [`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts)
+Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
 
 ### `compaction/*`
 

+ 2 - 2
docs/persistence-catalog.zh.md

@@ -270,7 +270,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts)
+来源:[`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts)
 
 <a id="commandrun--log-only"></a>
 
@@ -290,7 +290,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
 ```
 
-来源:[`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts)
+来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
 
 ### `compaction/*`
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
-attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11
-attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a
+attachment.md: 53b3e51e5b1c87625256178287061386342bc055
+attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1

+ 157 - 4
docs/subsystems/attachment.md

@@ -1,10 +1,10 @@
-# Durable Image Attachments
+# Durable Attachments
 
 English | [中文](attachment.zh.md)
 
-The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload.
+The attachment seam separates binary image and generic-file ownership from the session log. A producer gives bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible attachment blocks contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. The independent [`ctx.fileUploads`](#ctxfileuploads--fileuploads) service binds browser file transfers and staged receipts to the receiving Agent.
 
-Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `<DSH_HOME>/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule.
+Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Browser generic files become durable before they receive a staged prompt receipt. Once the host accepts a user message, its images move below `<DSH_HOME>/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule.
 
 Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts)
 
@@ -62,6 +62,38 @@ The reference records intrinsic dimensions and encoded length so clients can lay
 
 ## Commit and verified-read payloads
 
+```ts type-equiv
+/**
+ * Browser-submitted prompt content accepted by Host prompt endpoints; the
+ * accepting Host promotes image parts to durable references through
+ * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can
+ * never cite an attachment it did not upload.
+ */
+type PromptContentPart =
+  | { readonly type: 'text'; readonly text: string }
+  | {
+    readonly type: 'image'
+    readonly mediaType: ImageMediaType
+    readonly data: string
+    readonly name?: string
+  }
+```
+
+```ts type-equiv
+/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */
+type AttachmentAdmissionPart =
+  | PromptContentPart
+  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+```
+
+```ts type-equiv
+/** Host-admitted prompt content with every attachment represented by its durable reference. */
+type AdmittedPromptContentPart =
+  | { readonly type: 'text'; readonly text: string }
+  | { readonly type: 'image'; readonly attachment: ImageAttachmentRef }
+  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+```
+
 ```ts type-equiv
 /** Base64-encoded image upload accompanying one wire request. */
 interface EncodedImageAttachment {
@@ -125,7 +157,7 @@ interface RequestImageAttachment {
 }
 ```
 
-`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
+`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
  */
 async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
 
+/**
+ * Admit one Host prompt and replace each uploaded image with its durable reference.
+ * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation.
+ * @param content - prompt parts in message order after file receipt resolution.
+ * @returns admitted prompt parts in the same order as `content`.
+ * @throws AttachmentError when the image batch is refused.
+ */
+async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
+
+/**
+ * Decode and durably commit one canonical base64 file upload.
+ * @param input - canonical base64 bytes and optional display name.
+ * @returns the durable content-addressed file reference.
+ * @throws AttachmentError when the encoding or storage operation is refused.
+ */
+admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Identify a failure emitted by this attachment capability by its stable code.
+ * @param error - value caught from an attachment operation.
+ * @returns whether the value is an attachment failure.
+ */
+isAttachmentError(error: unknown): error is AttachmentError
+
 /**
  * Validate and durably commit one image before its owning session event is appended.
  * The returned reference describes the persisted normalized image. When
@@ -184,6 +240,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<Store
  */
 imageHostPath(ref: ImageAttachmentRef): string | undefined
 
+/**
+ * Durably commit one file byte-for-byte before its owning session event is
+ * appended. Files carry no admission limits: any byte content and length is
+ * accepted, and the stored object is the exact submitted bytes. Backends
+ * without verbatim file storage keep this default rejection.
+ * @param input - exact bytes and optional display name.
+ * @returns the durable content-addressed file reference.
+ */
+saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Durably commit one file byte-for-byte from bounded chunks. Providers must
+ * apply backpressure and must not collect the complete file in memory.
+ * Backends without streamed verbatim storage keep this default rejection.
+ * @param input - ordered exact bytes, optional cancellation, and display name.
+ * @returns the durable content-addressed file reference.
+ */
+saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Read and verify one verbatim stored file as bounded chunks. Providers must
+ * not collect the complete file in memory. Backends without verbatim file
+ * reads keep this default rejection.
+ * @param ref - durable reference from the session log.
+ * @param signal - optional cancellation for backend reads and verification work.
+ * @returns exact file bytes in order; integrity failures reject the iteration.
+ */
+async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable<Uint8Array>
+
+/**
+ * Locate the verbatim stored file object in the harness host filesystem.
+ * @param ref - durable file reference.
+ * @returns an absolute host path, or undefined when this backend is not host-file-backed.
+ * @throws an AttachmentError when the durable reference is invalid.
+ */
+fileHostPath(ref: FileAttachmentRef): string | undefined
+
 /**
  * Generate or read one deterministic model-request version from the stored normalized image.
  * @param ref - durable provider-independent normalized attachment reference.
@@ -195,4 +288,64 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?:
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts)
+
+<a id="ctxfileuploads--fileuploads"></a>
+
+### `ctx.fileUploads` — `FileUploads`
+
+Host service owning upload storage and Agent-scoped staged receipts.
+
+```ts cordis-catalog
+/**
+ * Register the ordinary-Session resolver used when a raw upload addresses a cold Session.
+ * @param resolve - resolver that returns the exact live Agent or throws a Remote error.
+ * @returns disposer removing this resolver.
+ */
+registerAgentResolver(resolve: AgentResolver): () => void
+
+/**
+ * Persist one encoded upload and stage it under the Agent receiver selected by Typert.
+ * @param agent - receiving Agent resolved from the Remote Agent scope.
+ * @param request - canonical base64 bytes and optional display name.
+ * @param signal - caller cancellation before storage begins.
+ * @returns the staged receipt and durable file reference.
+ */
+@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise<FileUploadValue>
+
+/**
+ * Persist raw chunks for one Session without aggregating the upload.
+ * @param request - Session identity, ordered bytes, cancellation, and optional display name.
+ * @returns the staged receipt and durable file reference.
+ */
+async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable<Uint8Array> readonly signal?: AbortSignal readonly name?: string }): Promise<FileUploadValue>
+
+/**
+ * Resolve one staged receipt inside its receiving Agent scope.
+ * @param agent - receiving Agent.
+ * @param receiptId - opaque receipt minted for one completed upload.
+ * @returns durable file reference, or `undefined` for an unknown or foreign receipt.
+ */
+resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined
+
+/**
+ * Bind receipts while one prompt enters an Agent inbox.
+ * Disposal restores every prior binding unless the caller commits successful delivery.
+ * @param agent - receiving Agent.
+ * @param receiptIds - distinct staged receipts referenced by the prompt.
+ * @param requestId - prompt identity later observed in queue or history.
+ * @returns binding kept after commit until queue or history observation retires its receipts.
+ */
+bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding
+
+/**
+ * Retire every receipt accepted by one removed queue occurrence.
+ * @param agent - receiving Agent.
+ * @param requestId - prompt identity carried by the queue occurrence.
+ */
+retirePrompt(agent: Agent, requestId: string): void
+```
+
+Types: [Agent](core.md) · [SessionId](core.md)
+
+Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 157 - 4
docs/subsystems/attachment.zh.md

@@ -1,10 +1,10 @@
-# 持久图片附件
+# 持久附件
 
 [English](attachment.md) | 中文
 
-附件 seam 将二进制图片的所有权与会话日志分离。生产方把经过校验的编码字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的 `ImageBlock` 包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。
+附件 seam 将二进制图片和通用文件的所有权与会话日志分离。生产方把字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的附件块包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。独立的 [`ctx.fileUploads`](#ctxfileuploads--fileuploads) 服务把浏览器文件传输与暂存凭证绑定到接收方 Agent。
 
-未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `<DSH_HOME>/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。
+未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。浏览器通用文件取得暂存 prompt 凭证前会完成持久化。宿主接受用户消息后,会先把消息中的图片移到 `<DSH_HOME>/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。
 
 来源:[`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts)
 
@@ -62,6 +62,38 @@ interface ImageAttachmentLimits {
 
 ## 提交与经校验读取的数据
 
+```ts type-equiv
+/**
+ * Browser-submitted prompt content accepted by Host prompt endpoints; the
+ * accepting Host promotes image parts to durable references through
+ * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can
+ * never cite an attachment it did not upload.
+ */
+type PromptContentPart =
+  | { readonly type: 'text'; readonly text: string }
+  | {
+    readonly type: 'image'
+    readonly mediaType: ImageMediaType
+    readonly data: string
+    readonly name?: string
+  }
+```
+
+```ts type-equiv
+/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */
+type AttachmentAdmissionPart =
+  | PromptContentPart
+  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+```
+
+```ts type-equiv
+/** Host-admitted prompt content with every attachment represented by its durable reference. */
+type AdmittedPromptContentPart =
+  | { readonly type: 'text'; readonly text: string }
+  | { readonly type: 'image'; readonly attachment: ImageAttachmentRef }
+  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+```
+
 ```ts type-equiv
 /** Base64-encoded image upload accompanying one wire request. */
 interface EncodedImageAttachment {
@@ -125,7 +157,7 @@ interface RequestImageAttachment {
 }
 ```
 
-`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
+`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
  */
 async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
 
+/**
+ * Admit one Host prompt and replace each uploaded image with its durable reference.
+ * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation.
+ * @param content - prompt parts in message order after file receipt resolution.
+ * @returns admitted prompt parts in the same order as `content`.
+ * @throws AttachmentError when the image batch is refused.
+ */
+async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
+
+/**
+ * Decode and durably commit one canonical base64 file upload.
+ * @param input - canonical base64 bytes and optional display name.
+ * @returns the durable content-addressed file reference.
+ * @throws AttachmentError when the encoding or storage operation is refused.
+ */
+admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Identify a failure emitted by this attachment capability by its stable code.
+ * @param error - value caught from an attachment operation.
+ * @returns whether the value is an attachment failure.
+ */
+isAttachmentError(error: unknown): error is AttachmentError
+
 /**
  * Validate and durably commit one image before its owning session event is appended.
  * The returned reference describes the persisted normalized image. When
@@ -184,6 +240,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<Store
  */
 imageHostPath(ref: ImageAttachmentRef): string | undefined
 
+/**
+ * Durably commit one file byte-for-byte before its owning session event is
+ * appended. Files carry no admission limits: any byte content and length is
+ * accepted, and the stored object is the exact submitted bytes. Backends
+ * without verbatim file storage keep this default rejection.
+ * @param input - exact bytes and optional display name.
+ * @returns the durable content-addressed file reference.
+ */
+saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Durably commit one file byte-for-byte from bounded chunks. Providers must
+ * apply backpressure and must not collect the complete file in memory.
+ * Backends without streamed verbatim storage keep this default rejection.
+ * @param input - ordered exact bytes, optional cancellation, and display name.
+ * @returns the durable content-addressed file reference.
+ */
+saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Read and verify one verbatim stored file as bounded chunks. Providers must
+ * not collect the complete file in memory. Backends without verbatim file
+ * reads keep this default rejection.
+ * @param ref - durable reference from the session log.
+ * @param signal - optional cancellation for backend reads and verification work.
+ * @returns exact file bytes in order; integrity failures reject the iteration.
+ */
+async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable<Uint8Array>
+
+/**
+ * Locate the verbatim stored file object in the harness host filesystem.
+ * @param ref - durable file reference.
+ * @returns an absolute host path, or undefined when this backend is not host-file-backed.
+ * @throws an AttachmentError when the durable reference is invalid.
+ */
+fileHostPath(ref: FileAttachmentRef): string | undefined
+
 /**
  * Generate or read one deterministic model-request version from the stored normalized image.
  * @param ref - durable provider-independent normalized attachment reference.
@@ -195,4 +288,64 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?:
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts)
+
+<a id="ctxfileuploads--fileuploads"></a>
+
+### `ctx.fileUploads` — `FileUploads`
+
+Host service owning upload storage and Agent-scoped staged receipts.
+
+```ts cordis-catalog
+/**
+ * Register the ordinary-Session resolver used when a raw upload addresses a cold Session.
+ * @param resolve - resolver that returns the exact live Agent or throws a Remote error.
+ * @returns disposer removing this resolver.
+ */
+registerAgentResolver(resolve: AgentResolver): () => void
+
+/**
+ * Persist one encoded upload and stage it under the Agent receiver selected by Typert.
+ * @param agent - receiving Agent resolved from the Remote Agent scope.
+ * @param request - canonical base64 bytes and optional display name.
+ * @param signal - caller cancellation before storage begins.
+ * @returns the staged receipt and durable file reference.
+ */
+@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise<FileUploadValue>
+
+/**
+ * Persist raw chunks for one Session without aggregating the upload.
+ * @param request - Session identity, ordered bytes, cancellation, and optional display name.
+ * @returns the staged receipt and durable file reference.
+ */
+async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable<Uint8Array> readonly signal?: AbortSignal readonly name?: string }): Promise<FileUploadValue>
+
+/**
+ * Resolve one staged receipt inside its receiving Agent scope.
+ * @param agent - receiving Agent.
+ * @param receiptId - opaque receipt minted for one completed upload.
+ * @returns durable file reference, or `undefined` for an unknown or foreign receipt.
+ */
+resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined
+
+/**
+ * Bind receipts while one prompt enters an Agent inbox.
+ * Disposal restores every prior binding unless the caller commits successful delivery.
+ * @param agent - receiving Agent.
+ * @param receiptIds - distinct staged receipts referenced by the prompt.
+ * @param requestId - prompt identity later observed in queue or history.
+ * @returns binding kept after commit until queue or history observation retires its receipts.
+ */
+bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding
+
+/**
+ * Retire every receipt accepted by one removed queue occurrence.
+ * @param agent - receiving Agent.
+ * @param requestId - prompt identity carried by the queue occurrence.
+ */
+retirePrompt(agent: Agent, requestId: string): void
+```
+
+Types: [Agent](core.zh.md) · [SessionId](core.zh.md)
+
+Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts)
 <!-- END GENERATED cordis-surface -->

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/commands.md
-commands.md: 46d3b736afd3a72d1119c9744cd592a86b9cbc29
-commands.zh.md: db8d6e722e3a1742887aaa4023ab67b9ed914552
+commands.md: 4705048e06796792d8e013450b72035b1f87315b
+commands.zh.md: f3af9f6e81745514efd1e073868292a02f90dca8

+ 24 - 15
docs/subsystems/commands.md

@@ -8,7 +8,7 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio
 
 ## Input metadata
 
-The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition.
+The service exposes one optional unstructured-input descriptor: a hint plus an attachment-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition.
 
 ```ts type-equiv
 /** Immutable metadata for a command's optional unstructured input. */
@@ -16,13 +16,13 @@ interface CommandInputDescriptor {
   /** Placeholder shown before the user supplies free-form input. */
   readonly hint: string
   /**
-   * Whether composer image attachments may accompany an invocation. Absent or
-   * false = the executor rejects an invocation carrying images and capable
+   * Whether composer attachments may accompany an invocation. Absent or
+   * false = the executor rejects an invocation carrying attachments and capable
    * composers refuse the submission before dispatch. A declaring command's
    * handler receives the admitted durable blocks and owns every further
    * grammar decision, including rejecting sub-commands that cannot use them.
    */
-  readonly images?: boolean
+  readonly attachments?: boolean
 }
 ```
 
@@ -64,13 +64,13 @@ interface CommandInvocation {
   /** Exact text following the registered command name, including separator whitespace. */
   readonly rawInput: string
   /**
-   * Durably admitted image blocks accompanying this invocation, in submission
-   * order; empty unless the definition declares `input.images`. The handler
+   * Durably admitted image and file blocks accompanying this invocation, in submission
+   * order; empty unless the definition declares `input.attachments`. The handler
    * owns their model-visible use — the registry never schedules them itself —
    * and a handler whose grammar cannot use them in this invocation returns an
    * error so the dispatching composer retains the originals.
    */
-  readonly attachments: readonly ImageBlock[]
+  readonly attachments: readonly (ImageBlock | FileBlock)[]
   /** Cancellation signal owned by the dispatching UI request. */
   readonly signal: AbortSignal
 }
@@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist
  */
 register(definition: CommandDefinition): () => void
 
+/**
+ * Register the sole authority that resolves staged file receipts for command submissions.
+ * @param resolver - Session-aware receipt resolver.
+ * @returns disposer that removes this exact resolver.
+ */
+registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void
+
 /**
  * List the effective immutable command descriptors for one agent.
  * @param agent - exact receiving agent and scoped-layer key.
@@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined
  * handler-failure path is contained so the handler's own error stays the
  * reported failure.
  *
- * Image admission is enforced here, not in the composer: images sent to a
- * command that does not declare `input.images`, an absent attachment store,
- * and an exceeded attachment limit each settle as an error result before
- * the handler runs, and a rejected batch publishes no durable object.
+ * Attachment admission is enforced here, not in the composer: attachments sent to a
+ * command that does not declare `input.attachments`, an absent attachment store,
+ * and an exceeded image limit each settle as an error result before
+ * the handler runs. Validation rejection starts no attachment writes;
+ * a storage failure can leave only unreachable content-addressed objects
+ * for deferred collection.
  *
  * @param agent - exact receiving agent.
  * @param line - complete slash-command line.
- * @param images - base64-encoded composer images accompanying the line, in
- *   submission order; empty for a plain invocation.
+ * @param submittedAttachments - encoded images and staged file receipts accompanying the line,
+ *   in submission order; empty for a plain invocation.
  * @param signal - cancellation signal owned by the UI request.
  * @returns the settled execution (result + lifecycle pairing id), or
  *   `undefined` when syntax or name does not resolve.
  */
-@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
+@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
 ```
 
-Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md)
+Types: [Agent](core.md)
 
 Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts)
 

+ 24 - 15
docs/subsystems/commands.zh.md

@@ -8,7 +8,7 @@
 
 ## 输入元数据
 
-该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。
+该服务公开一个可选的非结构化输入描述符:提示文本加附件接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。
 
 ```ts type-equiv
 /** Immutable metadata for a command's optional unstructured input. */
@@ -16,13 +16,13 @@ interface CommandInputDescriptor {
   /** Placeholder shown before the user supplies free-form input. */
   readonly hint: string
   /**
-   * Whether composer image attachments may accompany an invocation. Absent or
-   * false = the executor rejects an invocation carrying images and capable
+   * Whether composer attachments may accompany an invocation. Absent or
+   * false = the executor rejects an invocation carrying attachments and capable
    * composers refuse the submission before dispatch. A declaring command's
    * handler receives the admitted durable blocks and owns every further
    * grammar decision, including rejecting sub-commands that cannot use them.
    */
-  readonly images?: boolean
+  readonly attachments?: boolean
 }
 ```
 
@@ -64,13 +64,13 @@ interface CommandInvocation {
   /** Exact text following the registered command name, including separator whitespace. */
   readonly rawInput: string
   /**
-   * Durably admitted image blocks accompanying this invocation, in submission
-   * order; empty unless the definition declares `input.images`. The handler
+   * Durably admitted image and file blocks accompanying this invocation, in submission
+   * order; empty unless the definition declares `input.attachments`. The handler
    * owns their model-visible use — the registry never schedules them itself —
    * and a handler whose grammar cannot use them in this invocation returns an
    * error so the dispatching composer retains the originals.
    */
-  readonly attachments: readonly ImageBlock[]
+  readonly attachments: readonly (ImageBlock | FileBlock)[]
   /** Cancellation signal owned by the dispatching UI request. */
   readonly signal: AbortSignal
 }
@@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist
  */
 register(definition: CommandDefinition): () => void
 
+/**
+ * Register the sole authority that resolves staged file receipts for command submissions.
+ * @param resolver - Session-aware receipt resolver.
+ * @returns disposer that removes this exact resolver.
+ */
+registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void
+
 /**
  * List the effective immutable command descriptors for one agent.
  * @param agent - exact receiving agent and scoped-layer key.
@@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined
  * handler-failure path is contained so the handler's own error stays the
  * reported failure.
  *
- * Image admission is enforced here, not in the composer: images sent to a
- * command that does not declare `input.images`, an absent attachment store,
- * and an exceeded attachment limit each settle as an error result before
- * the handler runs, and a rejected batch publishes no durable object.
+ * Attachment admission is enforced here, not in the composer: attachments sent to a
+ * command that does not declare `input.attachments`, an absent attachment store,
+ * and an exceeded image limit each settle as an error result before
+ * the handler runs. Validation rejection starts no attachment writes;
+ * a storage failure can leave only unreachable content-addressed objects
+ * for deferred collection.
  *
  * @param agent - exact receiving agent.
  * @param line - complete slash-command line.
- * @param images - base64-encoded composer images accompanying the line, in
- *   submission order; empty for a plain invocation.
+ * @param submittedAttachments - encoded images and staged file receipts accompanying the line,
+ *   in submission order; empty for a plain invocation.
  * @param signal - cancellation signal owned by the UI request.
  * @returns the settled execution (result + lifecycle pairing id), or
  *   `undefined` when syntax or name does not resolve.
  */
-@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
+@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
 ```
 
-Types: [Agent](core.zh.md) · [EncodedImageAttachment](attachment.zh.md)
+Types: [Agent](core.zh.md)
 
 Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts)
 

+ 2 - 2
docs/subsystems/llm-streaming.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: d0d83897db4924de885f012212a200fa7d88b36e
-llm-streaming.zh.md: 962cea4bf6ec5c4db84d7613c7a2317693bf5c47
+llm-streaming.md: e88741c6bb63b819a314f74fc425c89b865b5906
+llm-streaming.zh.md: 4bc7fc6124e77c7e3bbbc474bff9bc79fbd8eb27

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

@@ -23,12 +23,13 @@ interface ContentBlockMap {
   'text': TextBlock
   'reasoning': ReasoningBlock
   'image': ImageBlock
+  'file': FileBlock
   'tool-call': ToolCallBlock
   'tool-result': ToolResultBlock
 }
 ```
 
-The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it.
+The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it.
 
 Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`.
 
@@ -961,6 +962,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy
  */
 imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined
 
+/**
+ * Resolve the exact text one durable file occurrence contributes to every
+ * provider request in the current execution environment.
+ * @param ref - durable verbatim file reference from model history.
+ * @returns the same deterministic handle text used at adapter dispatch.
+ */
+fileRequestText(ref: FileAttachmentRef): string
+
 /**
  * Discover models advertised by one registered provider. Catalog membership
  * is advisory and never changes routing or request validation.
@@ -1016,6 +1025,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
 stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 ```
 
+Types: [FileAttachmentRef](attachment.md)
+
 Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
 
 <a id="llm-events"></a>

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

@@ -23,12 +23,13 @@ interface ContentBlockMap {
   'text': TextBlock
   'reasoning': ReasoningBlock
   'image': ImageBlock
+  'file': FileBlock
   'tool-call': ToolCallBlock
   'tool-result': ToolResultBlock
 }
 ```
 
-各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。
+各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。
 
 图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。
 
@@ -967,6 +968,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy
  */
 imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined
 
+/**
+ * Resolve the exact text one durable file occurrence contributes to every
+ * provider request in the current execution environment.
+ * @param ref - durable verbatim file reference from model history.
+ * @returns the same deterministic handle text used at adapter dispatch.
+ */
+fileRequestText(ref: FileAttachmentRef): string
+
 /**
  * Discover models advertised by one registered provider. Catalog membership
  * is advisory and never changes routing or request validation.
@@ -1022,6 +1031,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
 stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 ```
 
+Types: [FileAttachmentRef](attachment.zh.md)
+
 Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
 
 <a id="llm-events"></a>

+ 3 - 1
packages/api/remotes/package.json

@@ -78,8 +78,10 @@
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-questions": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
-    "@deepseek-ai/dsh-api-session-controller": "workspace:^"
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "zod": "^4.4.3"
   }
 }

+ 3 - 1
packages/api/remotes/src/client/index.ts

@@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
+import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote'
 import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
 import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
 import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
@@ -24,6 +25,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote'
 export type {} from '@deepseek-ai/dsh-llm/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
+export type {} from '@deepseek-ai/dsh-client-file-upload/remote'
 export type {} from '@deepseek-ai/dsh-session-reference/remote'
 export type {} from '@deepseek-ai/dsh-subagent/remote'
 export type * from '@deepseek-ai/dsh-subagent/client'
@@ -145,7 +147,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
   try {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
-      pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
+      pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
       subagentsRemote, sessionRemote, workspaceRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))

+ 2 - 0
packages/api/remotes/tests/built-lib.e2e.ts

@@ -48,6 +48,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
     const script = `
       import { createServer } from 'node:http'
       import * as cordis from '@deepseek-ai/cordis'
+      import * as zod from 'zod'
 
       const urls = ${JSON.stringify(urls)}
       const { Context } = cordis
@@ -157,6 +158,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
         if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
         return handoff.factory(specifier => {
           if (specifier === '@deepseek-ai/cordis') return cordis
+          if (specifier === 'zod') return zod
           throw new Error('unexpected Client external ' + specifier)
         })
       }

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -20,6 +20,9 @@
     {
       "path": "../../client/connection/tsconfig.client.json"
     },
+    {
+      "path": "../../client/file-upload/tsconfig.client.json"
+    },
     {
       "path": "../../credentials/credentials"
 

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 1dbbeb34b845273d34b3323b6a4134da277126a0
-README.zh.md: 99c29a9043623b3d84fd12aeff42ec1781db699c
+README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6
+README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe

File diff suppressed because it is too large
+ 2 - 2
packages/api/session-controller/README.md


File diff suppressed because it is too large
+ 2 - 2
packages/api/session-controller/README.zh.md


+ 4 - 0
packages/api/session-controller/package.json

@@ -81,6 +81,8 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
@@ -119,6 +121,8 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",

+ 9 - 6
packages/api/session-controller/src/client/contract/session.ts

@@ -7,22 +7,25 @@
  * must stub); implementation-internal entry points (history staging, wire-frame
  * dispatch) stay on the class, invisible out here.
  */
-import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
-import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
+import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts'
 
 /**
  * Why a local submission echo left the snapshot: `observed` when its durable
  * `user/message` event or host queue occurrence arrived (with the admitted
- * image references in prompt order), `failed` when the prompt was rejected,
+ * attachment references in prompt order), `failed` when the prompt was rejected,
  * threw, or was aborted before acceptance.
  */
 export type PendingSubmissionRetirement =
-  | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] }
+  | {
+    readonly reason: 'observed'
+    readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]
+  }
   | { readonly reason: 'failed' }
 
 /** Input registering one local submission echo ahead of its prompt call. */
@@ -31,8 +34,8 @@ export interface BeginSubmissionInput {
   readonly mode: 'queue' | 'steer'
   /** Prompt text exactly as the upcoming prompt will send it. */
   readonly text: string
-  /** Ordered image previews matching the upcoming prompt's image parts. */
-  readonly images: readonly PendingSubmissionImage[]
+  /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */
+  readonly attachments: readonly PendingSubmissionAttachment[]
   /** Settlement callback fired exactly once when the echo retires. */
   readonly onRetire?: (retirement: PendingSubmissionRetirement) => void
 }

+ 20 - 2
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -1,5 +1,6 @@
 /** Session-owned observable state excluding Conversation target data. */
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
@@ -30,6 +31,23 @@ export interface PendingSubmissionImage {
   readonly height?: number
 }
 
+/** Image branch of a local submission echo attachment. */
+export interface PendingSubmissionImageAttachment {
+  readonly type: 'image'
+  readonly value: PendingSubmissionImage
+}
+
+/** File branch of a local submission echo attachment. */
+export interface PendingSubmissionFileAttachment {
+  readonly type: 'file'
+  readonly value: FileAttachmentRef
+}
+
+/** One attachment displayed by a local submission echo, in prompt order. */
+export type PendingSubmissionAttachment =
+  | PendingSubmissionImageAttachment
+  | PendingSubmissionFileAttachment
+
 /** Client surface selected when a local submission begins. */
 export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
 
@@ -48,8 +66,8 @@ export interface PendingSubmission {
   readonly time: number
   /** Prompt text exactly as it will be sent (one text block). */
   readonly text: string
-  /** Ordered image previews matching the prompt's image parts. */
-  readonly images: readonly PendingSubmissionImage[]
+  /** Ordered image previews and durable file metadata matching the prompt attachments. */
+  readonly attachments: readonly PendingSubmissionAttachment[]
 }
 
 /** History-open lifecycle of a Session event window. */

+ 7 - 0
packages/api/session-controller/src/client/index.ts

@@ -2,6 +2,8 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent/types'
+import type {} from '@deepseek-ai/dsh-client-connection/client'
+import type {} from '@deepseek-ai/dsh-client-file-upload/client'
 import { createSessionControlStream } from './transport.ts'
 import { ClientSessions } from './sessions/service.ts'
 import type { SessionRemotes } from './sessions/remotes.ts'
@@ -62,7 +64,10 @@ export type {
 export type {
   OpenState,
   PendingSubmission,
+  PendingSubmissionAttachment,
+  PendingSubmissionFileAttachment,
   PendingSubmissionImage,
+  PendingSubmissionImageAttachment,
   PendingSubmissionPlacement,
   PromptError,
   QueuedMessage,
@@ -78,6 +83,8 @@ declare module '@deepseek-ai/cordis' {
 
 /** Required Remote and Context projection services. */
 export const inject = [
+  'connection',
+  'fileUpload',
   'typert',
   'remote',
   'remote.commands',

+ 3 - 3
packages/api/session-controller/src/client/sessions/queue-mirror.ts

@@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts'
 
 const QUEUE_PREVIEW_CHARS = 200
 
-// Image blocks are excluded: queue presentation renders them as thumbnails
-// from `content`, so the text preview covers only what has no visual form.
+// Attachment blocks are excluded: queue presentation renders them from
+// `content`, so the text preview covers only what has no visual form.
 function previewOf(content: readonly ContentBlock[]): string {
   const flat = content
-    .filter(block => block.type !== 'image')
+    .filter(block => block.type !== 'image' && block.type !== 'file')
     .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
     .join(' ').replace(/\s+/g, ' ').trim()
   const chars = Array.from(flat)

+ 2 - 2
packages/api/session-controller/src/client/sessions/remotes.ts

@@ -5,8 +5,8 @@
  * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes
  */
 
-import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
   SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
@@ -19,7 +19,7 @@ export interface SessionCommandsRemote {
   execute(
     agentId: SessionId,
     line: string,
-    images: readonly EncodedImageAttachment[],
+    attachments: readonly CommandSubmitAttachment[],
     signal?: AbortSignal,
   ): Promise<RemoteResult<object | undefined>>
 }

+ 26 - 12
packages/api/session-controller/src/client/sessions/session.ts

@@ -2,7 +2,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
@@ -30,6 +30,7 @@ import type {
 } from '../contract/events.ts'
 import { Notifier } from './notifier.ts'
 import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionRemotes } from './remotes.ts'
 import { ProjectionValueStore } from './projection-store.ts'
@@ -210,7 +211,7 @@ export class Session implements SessionFace {
         : 'transcript',
       time: Date.now(),
       text: input.text,
-      images: input.images,
+      attachments: input.attachments,
     }]
     this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
     // The blank → engaging edge flips here, ahead of prompt(): the composer
@@ -222,7 +223,7 @@ export class Session implements SessionFace {
 
   /**
    * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
-   * @param content - text plus browser-owned temporary image uploads.
+   * @param content - text, browser-owned temporary image uploads, and staged-file receipts.
    * @param mode - queue appends after the current turn; steer interrupts it.
    * @param signal - optional caller cancellation for the complete admission round-trip.
    * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
@@ -252,13 +253,25 @@ export class Session implements SessionFace {
         content,
         clientTimeZone,
       }, signal)
+    } else if (content.some(part => part.type === 'file')) {
+      result = {
+        ok: false,
+        error: new RemoteError(
+          'subagent/attachment-invalid',
+          'subagent continuation does not accept files',
+          { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
+        ),
+      }
     } else {
+      // The preceding branch rejects file parts before the narrower subagent
+      // wire type is used; this array is not filtered or reordered.
+      const routedContent = content as Exclude<PromptContentPart, { readonly type: 'file' }>[]
       const routed = await this.remote.subagents.prompt({
         requestId: randomUUID() as SessionRequestId,
         parentSessionId: this.address.parentSessionId,
         childSessionId: this.address.childSessionId,
         mode: 'continuable',
-        content,
+        content: routedContent,
         clientTimeZone: resolvedClientTimeZone(),
       }, signal)
       result = routed.ok ? { ok: true, value: { accepted: true } } : routed
@@ -717,7 +730,7 @@ export class Session implements SessionFace {
     const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
     const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
     if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
-    this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content))
+    this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content))
   }
 
   /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
@@ -725,7 +738,7 @@ export class Session implements SessionFace {
     if (this.submissionSettlements.size === 0) return
     for (const item of items) {
       if (item.rpcId !== undefined) {
-        this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content))
+        this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content))
       }
     }
   }
@@ -738,7 +751,7 @@ export class Session implements SessionFace {
    */
   private scheduleObservedRetirement(
     requestId: SessionRequestId,
-    attachments: readonly ImageAttachmentRef[],
+    attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[],
   ): void {
     const settlement = this.submissionSettlements.get(requestId)
     if (settlement === undefined || settlement.retiring) return
@@ -816,15 +829,16 @@ function scheduleFrame(fn: () => void): void {
   else setTimeout(fn, 0)
 }
 
-/** Image attachment references in one structurally-read content block list, in block order. */
-function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
+/** Attachment references in one structurally-read content block list, in block order. */
+function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] {
   if (!Array.isArray(content)) return []
-  const refs: ImageAttachmentRef[] = []
+  const refs: Array<ImageAttachmentRef | FileAttachmentRef> = []
   for (const block of content) {
     if (typeof block !== 'object' || block === null) continue
     const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }
-    if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
-      refs.push(candidate.attachment as ImageAttachmentRef)
+    if ((candidate.type === 'image' || candidate.type === 'file')
+      && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
+      refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef)
     }
   }
   return refs

+ 61 - 3
packages/api/session-controller/src/commands.ts

@@ -4,8 +4,12 @@ import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import { brandString } from '@deepseek-ai/dsh-brand'
 import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
-import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import { AttachmentError } from '@deepseek-ai/dsh-attachment'
+import type {
+  AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
+} from '@deepseek-ai/dsh-attachment'
+import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
+import type {} from '@deepseek-ai/dsh-client-file-upload'
 import {
   ReasoningEffortId, createUserMessage, expandAssistantStream, freezeMessage,
 } from '@deepseek-ai/dsh-llm'
@@ -44,6 +48,7 @@ import type {
   SessionSelectModelValue,
   SessionUpdateQueueRequest,
   SessionUpdateQueueValue,
+  SessionRequestId,
 } from './types.ts'
 
 interface SessionReadState {
@@ -297,6 +302,7 @@ export class SessionCommandController {
       )
     }
     const agent = await this.resolveAgent(request.sessionId)
+    if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
     const selection = this.agents.selectionFor(agent).current
     if (!routeServed(this.ctx, selection.provider)) {
       throw new RemoteError(
@@ -324,10 +330,23 @@ export class SessionCommandController {
             )
           }
         }
-        const content = await admitPromptContent(this.ctx.attachments, request.content)
+        const admission = resolvePromptFileReceipts(
+          request.content,
+          receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
+        )
+        const content = await this.ctx.attachments.admitPromptContent(admission.content)
         const message: UserMessage = createUserMessage({ content, source })
+        if (this.ctx.agents.get(agent.id) !== agent) {
+          throw new RemoteError(
+            'session/not-found',
+            `session "${agent.id}" was disposed during prompt admission`,
+            { sessionId: agent.id },
+          )
+        }
+        using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
         if (request.mode === 'steer') agent.steer(message)
         else agent.followup(message)
+        binding.commit()
       } catch (error) {
         if (remoteErrorOf(error) !== undefined) throw error
         if (error instanceof AttachmentError) {
@@ -421,6 +440,12 @@ export class SessionCommandController {
       }))
     } else {
       agent.inbox.remove(request.itemId)
+      if (request.action.kind === 'remove') {
+        const source = message.source
+        if (source.kind === 'user' && 'rpcId' in source) {
+          this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
+        }
+      }
       if (request.action.kind === 'steer') agent.steer(message)
     }
     return { accepted: true }
@@ -497,6 +522,39 @@ export class SessionCommandController {
   }
 }
 
+function resolvePromptFileReceipts(
+  content: SessionPromptRequest['content'],
+  stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
+): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } {
+  const receiptIds = new Set<FileUploadReceiptId>()
+  const resolved = content.map((part): AttachmentAdmissionPart => {
+    if (part.type !== 'file') return part
+    const attachment = stagedFile(part.receiptId)
+    if (attachment === undefined) {
+      throw new RemoteError(
+        'session/attachment-invalid',
+        'File was not uploaded for this session.',
+        { reason: 'FILE_NOT_STAGED' },
+      )
+    }
+    receiptIds.add(part.receiptId)
+    return { type: 'file', attachment }
+  })
+  return { content: resolved, receiptIds: [...receiptIds] }
+}
+
+function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
+  const matches = (message: UserMessage): boolean => {
+    const source = message.source
+    return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
+  }
+  if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
+  return agent.session.snapshotEvents().some((event) => {
+    if (event.type !== 'user/message') return false
+    const source = event.data.source
+    return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
+  })
+}
 function imageBlockIn(
   content: unknown,
   match: (ref: ImageAttachmentRef) => boolean,

+ 7 - 0
packages/api/session-controller/src/index.ts

@@ -3,6 +3,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { errorChain } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-client-file-upload'
 import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
@@ -84,6 +85,7 @@ export class SessionController extends TypertRemoteService {
     'agentDefaultModel',
     'agents',
     'attachments',
+    'fileUploads',
     'llm',
     'sessions',
     'sessionProjections',
@@ -115,6 +117,11 @@ export class SessionController extends TypertRemoteService {
     installModelSelectionProjection(ctx)
     this.agents = new ApiSessionAgentController(ctx)
     this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
+    ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
+      const result = await this.agents.resolveAgent(sessionId)
+      if ('error' in result) throw result.error
+      return result.agent
+    }), 'session-controller: file-upload Agent resolver')
     this.controlState = new SessionControlController(ctx)
     // Registered before history so reverse-order teardown closes every
     // follower before waiting for already-admitted promotions.

+ 6 - 1
packages/api/session-controller/src/types.ts

@@ -67,7 +67,11 @@ export interface SessionProjectionBaseline {
 export type SessionProjectionValues = Partial<SessionProjectionMap>
   & Readonly<Record<string, SessionProjectionValue>>
 
-/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
+/**
+ * Browser-submitted prompt content; the Host promotes image bytes to durable
+ * references. File parts carry the opaque receipt returned by a preceding
+ * `uploadFile` call on the same Session.
+ */
 export type PromptContentPart =
   | { readonly type: 'text'; readonly text: string }
   | {
@@ -76,6 +80,7 @@ export type PromptContentPart =
     readonly data: string
     readonly name?: string
   }
+  | { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> }
 
 /** Complete model selection for one Session. */
 export interface ModelSelection {

+ 5 - 0
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -65,6 +65,11 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
     registerGenerationSource: () => () => {},
     start: () => ({ stop: () => {} }),
   }
+  ctx.reflect.provide('connection', connection)
+  ctx.reflect.provide('fileUpload', {
+    available: true,
+    post: () => Promise.reject(new Error('unexpected file upload')),
+  })
   ctx.reflect.provide('remote', {
     ...remote,
     $stream: <Item>(options: RemoteStreamOptions<Item>) => (

+ 3 - 2
packages/api/session-controller/tests/client-contract.client.spec.ts

@@ -73,8 +73,9 @@ describe('Client Session contracts', () => {
     expectTypeOf<SessionPageRequest['throughSeq']>().toEqualTypeOf<number>()
   })
 
-  it('keeps its catalog-visible prompt parts identical to attachment intake', () => {
-    expectTypeOf<SessionPromptContentPart>().toEqualTypeOf<AttachmentPromptContentPart>()
+  it('keeps its text and image prompt parts identical to attachment intake', () => {
+    expectTypeOf<Exclude<SessionPromptContentPart, { type: 'file' }>>()
+      .toEqualTypeOf<AttachmentPromptContentPart>()
   })
 
   it('publishes exact replace, prepend, and append event-window changes', () => {

+ 17 - 0
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -112,6 +112,23 @@ describe('Session queue commands', () => {
     })).toEqual({ accepted: true })
     expect(steer).toHaveBeenCalledWith(steered)
 
+    const queuedFile = createUserMessage({
+      content: [{
+        type: 'file',
+        attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 },
+      }],
+      source: { kind: 'user', rpcId: 'file-rpc' as never },
+    })
+    inbox.append('next-turn', queuedFile)
+    expect(controller.updateQueue({
+      sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
+    })).toEqual({ accepted: true })
+    expect(steer).toHaveBeenLastCalledWith(queuedFile)
+    expect(queuedFile).toMatchObject({
+      source: { kind: 'user', rpcId: 'file-rpc' },
+      content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }],
+    })
+
     await expectFailure(Promise.resolve().then(() => controller.cancel({
       sessionId: SessionId('missing'),
     })), 'session/not-found')

+ 471 - 0
packages/api/session-controller/tests/commands-upload-file.host.spec.ts

@@ -0,0 +1,471 @@
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
+import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
+import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import type {
+  FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
+} from '@deepseek-ai/dsh-attachment'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { UserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import CommandRuntime from '@deepseek-ai/dsh-commands'
+import { createScope } from '@deepseek-ai/dsh-scope'
+import FileUploads from '@deepseek-ai/dsh-client-file-upload'
+import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
+import { describe, expect, it, vi } from 'vitest'
+import type { ApiSessionAgentController } from '../src/agent.ts'
+import { SessionCommandController } from '../src/commands.ts'
+import type { SessionRequestId } from '../src/types.ts'
+
+const SESSION = SessionId('upload-session')
+
+async function uploadHarness(origin?: 'subagent'): Promise<{
+  ctx: Context
+  controller: SessionCommandController
+  uploads: FileUploads
+  agent: Agent
+  followup: ReturnType<typeof vi.fn>
+  saveFile: ReturnType<typeof vi.fn>
+  saveFileStream: ReturnType<typeof vi.fn>
+  saveImages: ReturnType<typeof vi.fn>
+  disposeAgent: () => void
+  uploadRoute: (request: Request) => Promise<Response>
+}> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(CommandRuntime)
+  const session = ctx.sessions.create(SESSION, {
+    meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
+  })
+  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
+  const followup = vi.fn()
+  const agent = {
+    id: session.id,
+    session,
+    inbox,
+    status: 'idle',
+    ctx: undefined,
+    steer: vi.fn(),
+    followup,
+    cancel: vi.fn(),
+  } as unknown as Agent
+  ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
+  const disposeAgent = ctx.agents.register(agent)
+  const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
+    attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
+    name: input.name ?? 'file',
+    bytes: input.data.byteLength,
+  }))
+  const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise<FileAttachmentRef> => {
+    let bytes = 0
+    for await (const chunk of input.data) bytes += chunk.byteLength
+    return {
+      attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`),
+      name: input.name ?? 'file',
+      bytes,
+    }
+  })
+  const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
+    Promise.reject(new Error('fixture did not expect image persistence')))
+  ctx.provide('attachments', Object.setPrototypeOf(
+    { saveFile, saveFileStream, saveImages },
+    AttachmentStore.prototype,
+  ) as never)
+  let uploadRoute: ((request: Request) => Promise<Response>) | undefined
+  ctx.provide('connection', {
+    fetch: {
+      register: (route: { readonly fetch: (request: Request) => Promise<Response> }) => {
+        uploadRoute = route.fetch
+        return () => {}
+      },
+    },
+  } as never)
+  ctx.provide('llm', {
+    listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
+    resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
+  } as never)
+  const selection: ModelSelectionRef = {
+    current: { provider: 'fixture', model: 'fixture-model' },
+    assembled: undefined,
+  }
+  const agents = {
+    resolveAgent: () => Promise.resolve({ agent }),
+    selectionFor: () => selection,
+    serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
+  } as unknown as ApiSessionAgentController
+  const uploads = new FileUploads(ctx)
+  if (uploadRoute === undefined) throw new Error('file upload route was not registered')
+  return {
+    ctx,
+    controller: new SessionCommandController(ctx, agents, '/workspace'),
+    uploads,
+    agent,
+    followup,
+    saveFile,
+    saveFileStream,
+    saveImages,
+    disposeAgent,
+    uploadRoute,
+  }
+}
+
+function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
+  return {
+    requestId: 'req-1' as SessionRequestId,
+    sessionId: SESSION,
+    mode: 'queue' as const,
+    content,
+  }
+}
+
+describe('Session file uploads', () => {
+  it('registers an HTTP route bound to the upload service', async () => {
+    const { uploadRoute } = await uploadHarness()
+    await expect(uploadRoute(new Request('http://host/upload')))
+      .resolves.toMatchObject({ status: 405 })
+  })
+
+  it('stages one verbatim upload and preserves its order with an admitted image', async () => {
+    const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness()
+    const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
+    expect(saveFile).toHaveBeenCalledTimes(1)
+    expect(receipt.file.name).toBe('notes.pdf')
+    expect(receipt.file.bytes).toBe(3)
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
+    expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined()
+    const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const }))
+    ctx.commands.register({
+      name: 'files', description: 'Use staged files', input: { hint: '<task>', attachments: true },
+      handler: commandHandler,
+    })
+    await ctx.commands.execute(
+      agent,
+      '/files inspect',
+      [{ type: 'file', receiptId: receipt.receiptId }],
+      new AbortController().signal,
+    )
+    expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
+      attachments: [{ type: 'file', attachment: receipt.file }],
+    })
+    const image: ImageAttachmentRef = {
+      attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`),
+      mediaType: 'image/png',
+      bytes: 3,
+      width: 1,
+      height: 1,
+    }
+    saveImages.mockResolvedValueOnce([image])
+    await controller.prompt(promptRequest([
+      { type: 'file', receiptId: receipt.receiptId },
+      { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+      { type: 'text', text: 'read it' },
+    ]))
+    expect(followup).toHaveBeenCalledTimes(1)
+    const message = followup.mock.calls[0]?.[0] as UserMessage
+    expect(message.content).toEqual([
+      { type: 'file', attachment: receipt.file },
+      { type: 'image', attachment: image },
+      { type: 'text', text: 'read it' },
+    ])
+  })
+
+  it('stages a bounded byte stream and forwards cancellation to storage', async () => {
+    const { controller, uploads, followup, saveFileStream } = await uploadHarness()
+    const abort = new AbortController()
+    const receipt = await uploads.uploadStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> {
+        yield Uint8Array.of(1, 2)
+        yield Uint8Array.of(3, 4, 5)
+      })(),
+      signal: abort.signal,
+      name: 'huge.bin',
+    })
+    expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({
+      signal: abort.signal,
+      name: 'huge.bin',
+    }))
+    expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
+      { type: 'file', attachment: receipt.file },
+    ])
+  })
+
+  it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
+    const { uploads, saveFileStream } = await uploadHarness()
+    const stream = (async function* (): AsyncIterable<Uint8Array> {})()
+    await expect(uploads.uploadStream({ sessionId: SESSION, data: stream }))
+      .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } })
+    expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream })
+    saveFileStream.mockRejectedValueOnce('disk offline')
+    await expect(uploads.uploadStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
+    }))
+      .rejects.toMatchObject({
+        code: 'gateway/internal', message: 'failed to store file upload: disk offline',
+      })
+  })
+
+  it('rejects a prompt citing a file that was never staged for the session', async () => {
+    const { controller, followup, saveImages } = await uploadHarness()
+    await expect(controller.prompt(promptRequest([
+      { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+      { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
+    ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
+    expect(followup).not.toHaveBeenCalled()
+    expect(saveImages).not.toHaveBeenCalled()
+  })
+
+  it('publishes no receipt when its exact Agent is disposed during storage', async () => {
+    const { uploads, agent, saveFile, disposeAgent } = await uploadHarness()
+    const saved = Promise.withResolvers<FileAttachmentRef>()
+    saveFile.mockReturnValueOnce(saved.promise)
+    const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
+    await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
+    disposeAgent()
+    saved.resolve({
+      attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
+    })
+    await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
+  })
+
+  it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
+    const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
+    disposeAgent()
+    const resolveAgent = vi.fn(async () => {
+      ctx.agents.register(agent)
+      return agent
+    })
+    const disposeResolver = uploads.registerAgentResolver(resolveAgent)
+    expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
+    await expect(uploads.uploadStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
+    })).resolves.toMatchObject({ file: { bytes: 1 } })
+    expect(resolveAgent).toHaveBeenCalledWith(SESSION)
+    disposeResolver()
+    const replacement = vi.fn(async () => agent)
+    const disposeReplacement = uploads.registerAgentResolver(replacement)
+    disposeResolver()
+    expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
+    expect(disposeReplacement).toBeTypeOf('function')
+    disposeReplacement()
+  })
+
+  it('rejects a cold upload when no Agent resolver is registered', async () => {
+    const { uploads, disposeAgent } = await uploadHarness()
+    disposeAgent()
+    await expect(uploads.uploadStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> {})(),
+    })).rejects.toMatchObject({ code: 'session/not-found' })
+  })
+
+  it('rejects subagent uploads and access outside the receiving Agent scope', async () => {
+    const child = await uploadHarness('subagent')
+    await expect(child.uploads.upload(
+      child.agent,
+      { data: 'AAAA' },
+      new AbortController().signal,
+    )).rejects.toMatchObject({
+      code: 'subagent/attachment-invalid',
+      details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
+    })
+    expect(child.saveFile).not.toHaveBeenCalled()
+
+    const ordinary = await uploadHarness()
+    const receipt = await ordinary.uploads.upload(
+      ordinary.agent,
+      { data: 'AAAA' },
+      new AbortController().signal,
+    )
+    const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent
+    expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId))
+      .toThrow('operation requires the Agent\'s own scope')
+  })
+
+  it('retires accepted receipts after their rpcId becomes observable', async () => {
+    const { controller, uploads, agent } = await uploadHarness()
+    uploads.retirePrompt(agent, 'not-staged')
+    const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
+    uploads.retirePrompt(agent, 'other-request')
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
+    uploads.retirePrompt(agent, 'req-1')
+    expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
+  })
+
+  it('retires observed prompt receipts and drops staged state with the Session', async () => {
+    const first = await uploadHarness()
+    const observed = await first.uploads.upload(
+      first.agent,
+      { data: 'AAAA' },
+      new AbortController().signal,
+    )
+    await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]))
+    first.ctx.emit('session/event', first.agent.session, {
+      type: 'user/message',
+      data: createUserMessage({
+        content: [{ type: 'text', text: 'extension event' }],
+        source: { kind: 'user', rpcId: 1 } as never,
+      }),
+    } as never)
+    expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file)
+    first.agent.session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'observed' }],
+      source: { kind: 'user', rpcId: 'req-1' as SessionRequestId },
+    }), { surfaceOp: 'append' })
+    expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined()
+
+    const second = await uploadHarness()
+    const abandoned = await second.uploads.upload(
+      second.agent,
+      { data: 'AAAA' },
+      new AbortController().signal,
+    )
+    second.ctx.emit('session/disposed', second.agent.session)
+    expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined()
+  })
+
+  it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
+    const { controller, agent, followup } = await uploadHarness()
+    const request = promptRequest([{ type: 'text', text: 'once' }])
+    await controller.prompt(request)
+    agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
+    await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
+    expect(followup).toHaveBeenCalledOnce()
+  })
+
+  it('deduplicates a retried rpcId already present in the durable log', async () => {
+    const { controller, agent, followup } = await uploadHarness()
+    const request = promptRequest([{ type: 'text', text: 'once' }])
+    agent.session.append('turn/start', { turn: 1 })
+    agent.session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'unidentified' }],
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    agent.session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'accepted' }],
+      source: { kind: 'user', rpcId: request.requestId },
+    }), { surfaceOp: 'append' })
+
+    await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
+    expect(followup).not.toHaveBeenCalled()
+  })
+
+  it('rejects when the Agent disappears during prompt admission', async () => {
+    const { controller, saveImages, disposeAgent } = await uploadHarness()
+    const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
+    saveImages.mockReturnValueOnce(admitted.promise)
+    const prompting = controller.prompt(promptRequest([
+      { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+    ]))
+    await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
+    disposeAgent()
+    admitted.resolve([{
+      attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
+    }])
+    await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' })
+  })
+
+  it('rejects when a previously bound receipt retires during image admission', async () => {
+    const { controller, uploads, agent, saveImages } = await uploadHarness()
+    const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
+    saveImages.mockReturnValueOnce(admitted.promise)
+    const prompting = controller.prompt({
+      ...promptRequest([
+        { type: 'file', receiptId: receipt.receiptId },
+        { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+      ]),
+      requestId: 'req-2' as SessionRequestId,
+    })
+    await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
+    uploads.retirePrompt(agent, 'req-1')
+    admitted.resolve([{
+      attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
+    }])
+    await expect(prompting).rejects.toMatchObject({
+      code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' },
+    })
+  })
+
+  it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
+    const { controller, uploads, agent, followup } = await uploadHarness()
+    const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    followup.mockImplementationOnce(() => { throw new Error('busy') })
+    await expect(controller.prompt({
+      ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
+      requestId: 'req-2' as SessionRequestId,
+    })).rejects.toMatchObject({ code: 'session/agent-busy' })
+    uploads.retirePrompt(agent, 'req-1')
+    expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
+  })
+
+  it('restores an unbound receipt after prompt delivery fails', async () => {
+    const { controller, uploads, agent, followup } = await uploadHarness()
+    const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
+    followup.mockImplementationOnce(() => { throw new Error('busy') })
+    await expect(controller.prompt(promptRequest([
+      { type: 'file', receiptId: receipt.receiptId },
+    ]))).rejects.toMatchObject({ code: 'session/agent-busy' })
+    uploads.retirePrompt(agent, 'req-1')
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
+  })
+
+  it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
+    const { controller, uploads, agent, followup } = await uploadHarness()
+    const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    const queued = followup.mock.calls[0]?.[0] as UserMessage
+    agent.inbox.append('next-turn', queued)
+    expect(controller.updateQueue({
+      sessionId: SESSION,
+      itemId: queued.id,
+      action: { kind: 'remove' },
+    })).toEqual({ accepted: true })
+    expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
+  })
+
+  it('keeps separate names for identical bytes uploaded more than once', async () => {
+    const { controller, uploads, agent, followup } = await uploadHarness()
+    const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal)
+    const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal)
+    expect(first.file.attachmentId).toBe(second.file.attachmentId)
+    expect(first.receiptId).not.toBe(second.receiptId)
+
+    await controller.prompt(promptRequest([
+      { type: 'file', receiptId: first.receiptId },
+      { type: 'file', receiptId: second.receiptId },
+    ]))
+
+    const message = followup.mock.calls[0]?.[0] as UserMessage
+    expect(message.content).toEqual([
+      { type: 'file', attachment: first.file },
+      { type: 'file', attachment: second.file },
+    ])
+  })
+
+  it('maps a non-canonical payload to the wire attachment error', async () => {
+    const { uploads, agent, saveFile } = await uploadHarness()
+    await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal))
+      .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } })
+    expect(saveFile).not.toHaveBeenCalled()
+  })
+
+  it('maps an unexpected storage failure to the internal wire error', async () => {
+    const { uploads, agent, saveFile } = await uploadHarness()
+    saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
+    await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal))
+      .rejects.toMatchObject({
+        code: 'gateway/internal',
+        message: 'failed to store file upload: Error: disk unavailable',
+      })
+  })
+})

+ 26 - 1
packages/api/session-controller/tests/controller.host.spec.ts

@@ -42,6 +42,16 @@ describe('SessionController facade', () => {
       list: () => Promise.resolve([header]),
       inspect,
     }) as never)
+    let uploadResolver: ((sessionId: SessionId) => Promise<Agent>) | undefined
+    ctx.provide('fileUploads', {
+      registerAgentResolver: (resolve: (sessionId: SessionId) => Promise<Agent>) => {
+        uploadResolver = resolve
+        return () => {}
+      },
+      resolve: () => undefined,
+      bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
+      retirePrompt: () => {},
+    } as never)
     const controller = createSessionTestController(ctx, defaults)
     const status = vi.fn()
     const failure = vi.fn()
@@ -65,6 +75,17 @@ describe('SessionController facade', () => {
       ctx,
     } as Agent
     ctx.agents.register(agent)
+    const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
+      if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
+      return uploadResolver(id)
+    }
+    await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent)
+    const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId })
+    vi.spyOn(
+      (controller as unknown as { agents: ApiSessionAgentController }).agents,
+      'resolveAgent',
+    ).mockResolvedValueOnce({ error: activationError })
+    await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
     const consumeSelection = vi.spyOn(
       (controller as unknown as { agents: ApiSessionAgentController }).agents,
       'consumeSelection',
@@ -83,6 +104,10 @@ describe('SessionController facade', () => {
       content: [{ type: 'text', text: 'hello' }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'browser prompt' }],
+      source: { kind: 'user', rpcId: 'controller-rpc' as never },
+    }), { surfaceOp: 'append' })
     expect(status).toHaveBeenCalledWith(sessionId, true)
     expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
     expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
@@ -108,7 +133,7 @@ describe('SessionController facade', () => {
     }, abort.signal)[Symbol.asyncIterator]()
     await expect(iterator.next()).resolves.toMatchObject({
       done: false,
-      value: { type: 'snapshot', cursor: 1 },
+      value: { type: 'snapshot', cursor: 2 },
     })
     abort.abort()
     await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })

+ 13 - 5
packages/api/session-controller/tests/queue-store.client.spec.ts

@@ -73,21 +73,29 @@ describe('Session queue snapshot intake', () => {
     ])
   })
 
-  it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => {
+  it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => {
     const session = makeSession()
     session.handleControlFrame(queueFrame([{
       id: 'q-image',
       body: '',
-      content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
+      content: [
+        { type: 'text', text: 'hi' },
+        { type: 'image', data: 'x' } as never,
+        { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never,
+      ],
     }]))
     const queue = session.getSnapshot().queue
     expect(typeof queue[0]?.messageId).toBe('string')
     expect(queue).toMatchObject([
       {
         id: 'q-image', placement: 'queued',
-        content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
-        // Image blocks render as thumbnails from `content`, so the preview
-        // carries only the text; non-image foreign blocks keep their marker.
+        content: [
+          { type: 'text', text: 'hi' },
+          { type: 'image', data: 'x' },
+          { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } },
+        ],
+        // Attachment blocks render from `content`, so the preview carries
+        // only text; other foreign blocks keep their marker.
         preview: 'hi', text: null,
       },
     ])

+ 13 - 4
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -8,14 +8,14 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
 import {
   SessionPersistenceRevision,
@@ -42,6 +42,10 @@ function promptRequest(
   }
 }
 
+function inboxFor(session: Session): Inbox {
+  return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
+}
+
 function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
   return { version: SESSION_FORMAT_VERSION, id: sid(id), createdAt, isSeeded: false, cwd: '/proj', ...extra }
 }
@@ -540,7 +544,9 @@ describe('subagent ownership fence', () => {
       inheritedEventCount: SessionLogOffset(1),
     })
     const followup = vi.fn()
-    const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
+    const agent = {
+      id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
+    } as unknown as Agent
     ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
@@ -559,7 +565,9 @@ describe('subagent ownership fence', () => {
     await ctx.plugin(AgentRegistry)
     const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
     const followup = vi.fn()
-    const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
+    const agent = {
+      id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
+    } as unknown as Agent
     ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, {
       defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -679,6 +687,7 @@ describe('sessions.prompt synchronous rejection', () => {
     ctx.agents.register({
       id: session.id,
       session,
+      inbox: inboxFor(session),
       status: 'idle',
       ctx,
       followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },

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

@@ -665,7 +665,7 @@ describe('Web session model selection', () => {
     const savedRef = {
       attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1,
     }
-    ctx.provide('attachments', {
+    ctx.provide('attachments', Object.setPrototypeOf({
       saveImages: () => {
         if (saveMode === 'error') return Promise.reject(new Error('image store offline'))
         if (saveMode === 'remote') {
@@ -673,7 +673,7 @@ describe('Web session model selection', () => {
         }
         return Promise.resolve([savedRef])
       },
-    } as never)
+    }, AttachmentStore.prototype) as never)
     const followup = vi.fn()
     Object.assign(agent, { followup })
     const remote = createSessionTestRemote(ctx, {

+ 60 - 22
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -2,7 +2,7 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Session } from '../src/client/sessions/session.ts'
@@ -31,8 +31,20 @@ function imageRef(id: string): ImageAttachmentRef {
   } as unknown as ImageAttachmentRef
 }
 
+function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
+  return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef
+}
+
+type AttachmentRef = ImageAttachmentRef | FileAttachmentRef
+
+function attachmentBlock(attachment: AttachmentRef) {
+  return 'mediaType' in attachment
+    ? { type: 'image' as const, attachment }
+    : { type: 'file' as const, attachment }
+}
+
 /** A durable browser-prompt user/message whose source echoes `rpcId`. */
-function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent {
+function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent {
   return {
     seq,
     time: 1_700_000_000_000 + seq,
@@ -40,7 +52,7 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im
     surfaceOp: 'append',
     data: createUserMessage({
       content: [
-        ...refs.map(attachment => ({ type: 'image' as const, attachment })),
+        ...refs.map(attachmentBlock),
         { type: 'text' as const, text: '发送' },
       ],
       source: { kind: 'user', rpcId },
@@ -48,14 +60,14 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im
   } as unknown as SessionEvent
 }
 
-function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem {
+function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem {
   return {
     id: 'm-queued' as SessionQueuedItem['id'],
     placement: 'queued',
     rpcId,
     message: {
       id: 'm-queued' as SessionQueuedItem['id'],
-      content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'],
+      content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'],
     },
   }
 }
@@ -72,23 +84,27 @@ describe('beginSubmission', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '你好',
-      images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
+      attachments: [{
+        type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
+      }],
     })
     expect(session.getSnapshot().promptAttempted).toBe(true)
     expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
       requestId: handle.requestId,
       placement: 'transcript',
       text: '你好',
-      images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
+      attachments: [{
+        type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
+      }],
     }])
   })
 
   it('derives and captures the echo placement from running state and delivery mode', () => {
     const { session } = makeSession()
-    session.beginSubmission({ mode: 'queue', text: '空闲', images: [] })
+    session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
     session.handleRunning(true)
-    session.beginSubmission({ mode: 'queue', text: '排队', images: [] })
-    session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] })
+    session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
+    session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
     session.handleRunning(false)
     expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
       { text: '空闲', placement: 'transcript' },
@@ -103,7 +119,7 @@ describe('beginSubmission', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '放弃',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     handle.abandon()
@@ -121,7 +137,7 @@ describe('prompt-coupled retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '失败的',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
@@ -133,7 +149,7 @@ describe('prompt-coupled retirement', () => {
 
   it('sends the echo identity as the prompt requestId', async () => {
     const { api, session } = makeSession()
-    const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] })
+    const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
     await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
     expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
   })
@@ -141,7 +157,7 @@ describe('prompt-coupled retirement', () => {
   it('an unidentified prompt failure leaves registered echoes alone', async () => {
     const { api, session } = makeSession()
     api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
-    session.beginSubmission({ mode: 'queue', text: '还在', images: [] })
+    session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
     await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
   })
@@ -156,7 +172,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '发送',
-      images: [{ previewUrl: 'blob:p1' }],
+      attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-1')]
@@ -176,7 +192,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '排队',
-      images: [{ previewUrl: 'blob:p1' }],
+      attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-q')]
@@ -188,9 +204,31 @@ describe('observed retirement', () => {
     expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
   })
 
+  it('retires a mixed echo with durable references in original selection order', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+    await session.open()
+    const retirements: PendingSubmissionRetirement[] = []
+    const file = fileRef('file-1')
+    const handle = session.beginSubmission({
+      mode: 'queue',
+      text: 'mixed',
+      attachments: [
+        { type: 'image', value: { previewUrl: 'blob:first' } },
+        { type: 'file', value: file },
+        { type: 'image', value: { previewUrl: 'blob:last' } },
+      ],
+      onRetire: retirement => retirements.push(retirement),
+    })
+    const refs = [imageRef('image-1'), file, imageRef('image-2')]
+    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never })
+    await settleFrames()
+    expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
+  })
+
   it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
     const { api, session } = makeSession()
-    const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] })
+    const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
     api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)])))
     await session.open()
     await settleFrames()
@@ -205,7 +243,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '先观察',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
@@ -222,7 +260,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '同一请求',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     session.handleControlFrame({
@@ -245,7 +283,7 @@ describe('observed retirement', () => {
     const { api, session } = makeSession()
     api.onHistory = () => Promise.resolve(ok(historyValue([])))
     await session.open()
-    const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] })
+    const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
     await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
     expect(frames).toHaveLength(1)
@@ -263,13 +301,13 @@ describe('disposal', () => {
     const observed = session.beginSubmission({
       mode: 'queue',
       text: '已观察',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push({ text: '已观察', retirement }),
     })
     session.beginSubmission({
       mode: 'queue',
       text: '未settle',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push({ text: '未settle', retirement }),
     })
     await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never })

+ 23 - 1
packages/api/session-controller/tests/session.client.spec.ts

@@ -15,7 +15,6 @@ const PARENT = 'fk-parent' as SessionId
 afterEach(() => {
   vi.unstubAllGlobals()
 })
-
 function makeSession(
   api = new FakeApiClient(),
   options: SessionOptions = {},
@@ -479,6 +478,29 @@ describe('prompt and cancel errors', () => {
     })
   })
 
+  it('rejects staged files instead of dropping them from subagent continuations', async () => {
+    const api = new FakeApiClient()
+    const session = new Session(SID, fakeRemote(api), {
+      address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+      parentAvailable: true,
+    })
+    await session.open()
+
+    const prompted = await session.prompt([
+      { type: 'file', receiptId: 'receipt' as never },
+      { type: 'text', text: '继续' },
+    ], 'queue')
+
+    expect(prompted).toMatchObject({
+      ok: false,
+      error: {
+        code: 'subagent/attachment-invalid',
+        details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
+      },
+    })
+    expect(api.callsOf('subagents.prompt')).toEqual([])
+  })
+
   it('sends a one-shot address to the Host under the continuable marker', async () => {
     const api = new FakeApiClient()
     api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(

+ 37 - 0
packages/api/session-controller/tests/test-remote.ts

@@ -3,6 +3,11 @@
 import { SessionLogOffset } from '@deepseek-ai/dsh-session'
 import type { Context } from '@deepseek-ai/cordis'
 import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
+import type {
+  AdmittedPromptContentPart,
+  AttachmentAdmissionPart,
+  ImageAttachmentLimits,
+} from '@deepseek-ai/dsh-attachment'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import {
   SessionPersistenceNotFoundError,
@@ -91,6 +96,15 @@ export interface TestSessionRemoteDefaults {
 
 const installed = new WeakMap<Context, SessionController>()
 
+const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({
+  maxImageBytes: 5 * 1024 * 1024,
+  maxImagesPerMessage: 20,
+  maxMessageImageBytes: 100 * 1024 * 1024,
+  maxImagePixels: 40_000_000,
+  maxImageDimension: 2000,
+  mediaTypes: Object.freeze(['image/png'] as const),
+})
+
 /** Compact header-and-events point read a persistence double declares per session. */
 interface TestSessionInspection {
   readonly meta: SessionHeader
@@ -233,6 +247,29 @@ function installControllers(
       },
     } as never)
   }
+  if (ctx.get('attachments') === undefined) {
+    ctx.provide('attachments', {
+      imageLimits: TEST_IMAGE_LIMITS,
+      admitPromptContent: async (
+        content: readonly AttachmentAdmissionPart[],
+      ): Promise<AdmittedPromptContentPart[]> => {
+        const admitted: AdmittedPromptContentPart[] = []
+        for (const part of content) {
+          if (part.type === 'image') throw new Error('test did not configure image persistence')
+          admitted.push(part)
+        }
+        return admitted
+      },
+    } as never)
+  }
+  if (ctx.get('fileUploads') === undefined) {
+    ctx.provide('fileUploads', {
+      registerAgentResolver: () => () => {},
+      resolve: () => undefined,
+      bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
+      retirePrompt: () => {},
+    } as never)
+  }
   installSessionReadTestServices(ctx)
   const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
   let controller: SessionController

+ 1 - 0
packages/api/session-controller/tsconfig.client.json

@@ -15,6 +15,7 @@
     { "path": "../gateway/tsconfig.client.json" },
     { "path": "../../attachment/attachment" },
     { "path": "../../client/connection/tsconfig.client.json" },
+    { "path": "../../client/file-upload/tsconfig.client.json" },
     { "path": "../../client/store" },
     { "path": "../../context/file-reference" },
     { "path": "../../core/session" },

+ 1 - 0
packages/api/session-controller/tsconfig.host.json

@@ -29,6 +29,7 @@
     { "path": "../../core/session" },
     { "path": "../../context/file-reference" },
     { "path": "../../attachment/attachment" },
+    { "path": "../../client/file-upload/tsconfig.host.json" },
     { "path": "../../interaction/permission-presets" },
     { "path": "../../jobs/jobs" },
     { "path": "../../llm/llm" },

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
-README.md: 5e9bda9aa13939c3945d005ea474c3caf8daa661
-README.zh.md: 88dcbff120c4c61ea776e43fc906923f039ee09b
+README.md: 364153b7b56daa725003178b6cfad90e3f94bc04
+README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b

+ 6 - 3
packages/attachment/attachment-local/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately. It is what the shipped `dsh` composition uses, so durable image attachments work without configuration. Identical normalized images are stored only once, concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine — other hosts cannot read these images — and objects are never deleted automatically.
+This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ This package provides the local storage and image-processing backend for attachm
 <a id="use-this-package"></a>
 ## Use this package
 
-In the default composition, attach images to a prompt or command and they are stored on this machine automatically. If you compose your own setup, mounting this one plugin gives you durable image attachments.
+In the default composition, images and generic files attached to prompts or commands are stored on this machine automatically. If you compose your own setup, mounting this plugin provides durable attachments.
 
 ### Minimal configuration
 
@@ -87,12 +87,15 @@ Admission accepts up to 20 images and 200 MiB of source bytes per message; one s
 
 Request versions live below `<DSH_HOME>/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
 
+Generic-file bytes have one canonical object at `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`. Each reference path at `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes.
+
 ### Source map
 
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Plugin entry: `LocalAttachmentStore`, `Config` schema, defaults |
 | [`src/store.ts`](src/store.ts) | Content-addressed write and verified read: staging, hard-link publish, fsync chain, digest verification |
+| [`src/file-store.ts`](src/file-store.ts) | Verbatim streamed file writes, verified streamed reads, and safe stored filenames |
 | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates |
 | [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight |
 | [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification |
@@ -117,7 +120,7 @@ For the full service contract and payload types, read the subsystem reference; f
 <a id="model-experience"></a>
 ## Model Experience
 
-Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes.
+Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. Generic files project as text handles naming their identity and read-only process path; when no mapping exists, the handle states that the execution environment cannot read the file.
 
 #### KV Cache effect
 

+ 6 - 3
packages/attachment/attachment-local/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存。随附的 `dsh` 组合使用的就是它,因此持久图片附件无需配置即可工作。相同规范化图片只存一份,同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机——其他主机无法读取这些图片——对象也永远不会自动删除。
+本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-在默认组合中,把图片附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这个插件即可获得持久图片附件。
+在默认组合中,把图片或通用文件附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这个插件即可获得持久附件。
 
 ### 最小配置
 
@@ -87,12 +87,15 @@ kind: "package-reference"
 
 请求版本位于 `<DSH_HOME>/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
 
+通用文件字节的唯一规范对象位于 `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`。每条引用路径 `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
+
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 插件入口:`LocalAttachmentStore`、`Config` schema、默认值 |
 | [`src/store.ts`](src/store.ts) | 内容寻址写入与校验读取:暂存、硬链接发布、fsync 链、摘要校验 |
+| [`src/file-store.ts`](src/file-store.ts) | 原样文件的流式写入、校验式流式读取与安全存储文件名 |
 | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 |
 | [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight |
 | [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 |
@@ -117,7 +120,7 @@ kind: "package-reference"
 <a id="model-experience"></a>
 ## 模型体验
 
-本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。
+本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。通用文件会投影为指出文件身份与只读进程路径的文本句柄;无法映射时,句柄会说明当前执行环境不能读取该文件。
 
 #### KV Cache 影响
 

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