Quellcode durchsuchen

refactor(attachment): isolate file upload service

creatixchu vor 2 Tagen
Ursprung
Commit
8fc9a11c5e
61 geänderte Dateien mit 1242 neuen und 961 gelöschten Zeilen
  1. 2 2
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
  2. 0 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
  3. 0 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
  4. 2 2
      docs/subsystems/attachment.i18n.yaml
  5. 62 3
      docs/subsystems/attachment.md
  6. 62 3
      docs/subsystems/attachment.zh.md
  7. 2 2
      docs/subsystems/session.i18n.yaml
  8. 0 9
      docs/subsystems/session.md
  9. 0 9
      docs/subsystems/session.zh.md
  10. 1 0
      packages/api/remotes/package.json
  11. 3 1
      packages/api/remotes/src/client/index.ts
  12. 3 0
      packages/api/remotes/tsconfig.client.json
  13. 2 2
      packages/api/session-controller/README.i18n.yaml
  14. 3 3
      packages/api/session-controller/README.md
  15. 3 3
      packages/api/session-controller/README.zh.md
  16. 2 2
      packages/api/session-controller/package.json
  17. 1 16
      packages/api/session-controller/src/client/contract/session.ts
  18. 1 95
      packages/api/session-controller/src/client/sessions/session.ts
  19. 8 146
      packages/api/session-controller/src/commands.ts
  20. 0 116
      packages/api/session-controller/src/file-upload-http.ts
  21. 0 2
      packages/api/session-controller/src/file-upload-path.ts
  22. 7 22
      packages/api/session-controller/src/index.ts
  23. 2 21
      packages/api/session-controller/src/types.ts
  24. 122 76
      packages/api/session-controller/tests/commands-upload-file.host.spec.ts
  25. 0 20
      packages/api/session-controller/tests/controller.host.spec.ts
  26. 0 8
      packages/api/session-controller/tests/fake-api.client.ts
  27. 30 48
      packages/api/session-controller/tests/file-upload-http.host.spec.ts
  28. 0 184
      packages/api/session-controller/tests/session.client.spec.ts
  29. 8 0
      packages/api/session-controller/tests/test-remote.ts
  30. 1 2
      packages/api/session-controller/tsconfig.client.json
  31. 1 2
      packages/api/session-controller/tsconfig.host.json
  32. 2 2
      packages/client/file-upload/README.i18n.yaml
  33. 12 8
      packages/client/file-upload/README.md
  34. 12 8
      packages/client/file-upload/README.zh.md
  35. 42 5
      packages/client/file-upload/package.json
  36. 19 21
      packages/client/file-upload/src/client/contract.ts
  37. 4 5
      packages/client/file-upload/src/client/index.ts
  38. 124 2
      packages/client/file-upload/src/client/runtime.ts
  39. 313 3
      packages/client/file-upload/src/index.ts
  40. 2 0
      packages/client/file-upload/src/protocol.ts
  41. 22 0
      packages/client/file-upload/src/types.ts
  42. 135 17
      packages/client/file-upload/tests/file-upload.client.spec.ts
  43. 22 0
      packages/client/file-upload/tsconfig.client.json
  44. 20 0
      packages/client/file-upload/tsconfig.host.json
  45. 3 11
      packages/client/file-upload/tsconfig.json
  46. 2 1
      packages/client/ui-chat/src/client/conversation-nodes/compaction.ts
  47. 2 0
      packages/client/ui-conversation/package.json
  48. 6 9
      packages/client/ui-conversation/src/client/apply.ts
  49. 1 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  50. 15 11
      packages/client/ui-conversation/src/client/service.ts
  51. 23 11
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  52. 0 1
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  53. 38 19
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  54. 1 1
      packages/experimental/webworker-runtime/tsconfig.json
  55. 54 14
      packages/extensions/tool-cordis/src/api-catalog.ts
  56. 0 8
      packages/test-support/client-runtime/src/sessions.ts
  57. 0 1
      packages/test-support/client-runtime/tests/runtime.client.spec.tsx
  58. 33 0
      pnpm-lock.yaml
  59. 5 2
      scripts/gen-cordis-catalog.ts
  60. 1 1
      tsconfig.client.json
  61. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.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-26-generic-file-upload.md
-2026-08-26-generic-file-upload.md: bc8eb9783096e5e7763969630b57419a64c0cb9f
-2026-08-26-generic-file-upload.zh.md: dbc392d700a84a77d321e5637be34da8c113a331
+2026-08-26-generic-file-upload.md: b1beeb5afd50a1ff03ba583567202608260e8ebe
+2026-08-26-generic-file-upload.zh.md: 0407cb76967c27831dfd8401da422d448290637c

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md


+ 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: 8c9199bc246fa8866cb5a2bba0edcd187bf779ac
-attachment.zh.md: d651d095e1157601086a94f7f4c2116c3bb5dee3
+attachment.md: 19c659508936552a449b7b4ee9a085f015ed8c14
+attachment.zh.md: 9942c7a9d2d7b37e10c946c10297cbdada0936c2

+ 62 - 3
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)
 
@@ -232,4 +232,63 @@ 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 to an accepted prompt and return a rollback for delivery failure.
+ * @param agent - receiving Agent.
+ * @param receiptIds - distinct staged receipts referenced by the prompt.
+ * @param requestId - prompt identity later observed in queue or history.
+ * @returns rollback restoring every prior binding.
+ */
+bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void
+
+/**
+ * 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 -->

+ 62 - 3
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)
 
@@ -232,4 +232,63 @@ 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 to an accepted prompt and return a rollback for delivery failure.
+ * @param agent - receiving Agent.
+ * @param receiptIds - distinct staged receipts referenced by the prompt.
+ * @param requestId - prompt identity later observed in queue or history.
+ * @returns rollback restoring every prior binding.
+ */
+bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void
+
+/**
+ * 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/session.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/session.md
-session.md: a22c8744262a785423f851fdc843cea44a2eb164
-session.zh.md: 61816c98e40f30186174703d92ea7473ce4f5c07
+session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31
+session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd

+ 0 - 9
docs/subsystems/session.md

@@ -764,15 +764,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue>
 
-/**
- * Persist one encoded file upload verbatim and stage it for a later prompt
- * on the same Session.
- * @param request - Session identity, base64 payload, and optional display name.
- * @param signal - caller cancellation before storage begins.
- * @returns an opaque per-upload receipt and the durable file reference.
- */
-@Remote('uploadFile') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue>
-
 /**
  * Mutate one still-pending queue occurrence on a live Agent.
  * @param request - Session, queue item, and requested mutation.

+ 0 - 9
docs/subsystems/session.zh.md

@@ -768,15 +768,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue>
 
-/**
- * Persist one encoded file upload verbatim and stage it for a later prompt
- * on the same Session.
- * @param request - Session identity, base64 payload, and optional display name.
- * @param signal - caller cancellation before storage begins.
- * @returns an opaque per-upload receipt and the durable file reference.
- */
-@Remote('uploadFile') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue>
-
 /**
  * Mutate one still-pending queue occurrence on a live Agent.
  * @param request - Session, queue item, and requested mutation.

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

@@ -78,6 +78,7 @@
     "@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:^"

+ 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))

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

@@ -20,6 +20,9 @@
     {
       "path": "../../client/connection/tsconfig.client.json"
     },
+    {
+      "path": "../../client/file-upload/tsconfig.host.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: ab4001052306be1b052d1ca6a0e8ff8ddeffd459
-README.zh.md: 5100959e826e1099007a85d34b345a7d7d8481e8
+README.md: effcf3fe21b8091ee0eb012dd80dfe2f4e75d394
+README.zh.md: 6f1a260f60377c6a8079ffbde757fe4dbada9337

+ 3 - 3
packages/api/session-controller/README.md

@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, Agent-scoped file references, and generic file upload. Use it through API Gateway when a Client needs operations addressed by a Session.
+`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
 
 ## Table of Contents
 
@@ -25,9 +25,9 @@ English | [中文](README.zh.md)
 
 History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
 
-Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, file-reference, and file-upload operations may resolve or resume an ordinary Session. A generated Remote accepts base64 file bytes for non-browser carriers, while an authenticated exact Fetch route streams raw chunks from the browser background-upload carrier without entering Connection's buffered JSON path. The streaming path hashes and writes each chunk with backpressure, publishes the completed object atomically, and removes its staging file on cancellation or failure. Both paths store the file verbatim and stage it in this process for the same exact Agent; completion after that Agent is disposed stores no receipt. A later prompt or attachment-accepting command cites the opaque per-upload receipt. Both admission paths validate every same-Session receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message; prompt receipts retire when that `rpcId` becomes a user event or its queued occurrence is removed, while command-only receipts remain until Session disposal. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
+Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and validates every same-Agent receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
 
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. A Client Session resolves the independent [`fileUpload`](../../client/file-upload/README.md) service from its Agent scope for `Blob` and one-shot `ReadableStream<Uint8Array>` bodies. A fixture or bare Session keeps the generated Remote fallback for `Blob` and exact-byte inputs; a stream requires the background service because it cannot be replayed through the base64 Remote.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
 
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 

+ 3 - 3
packages/api/session-controller/README.zh.md

@@ -8,7 +8,7 @@ kind: "package-reference"
 
 ## 概述
 
-`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现、面向 Agent 的文件引用和通用文件上传。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
+`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现和面向 Agent 的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
 
 ## 目录
 
@@ -25,9 +25,9 @@ kind: "package-reference"
 
 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
 
-每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、文件引用和文件上传操作可以解析或恢复普通 Session。生成 Remote 为非浏览器载体接收 base64 文件字节,经过认证的精确 Fetch 路由则从浏览器后台上传载体流式接收原始分块,不进入 Connection 的缓冲 JSON 路径。流式路径以背压逐块计算摘要和写入,完整接收后以原子方式发布对象,并在取消或失败时移除暂存文件。两条路径都按字节原样存储文件,并在本进程内按同一个准确 Agent 暂存;如果保存完成时该 Agent 已销毁,就不发布凭证。之后的 prompt 或接受附件的命令引用每次上传独有的不透明凭证。两条准入路径都先验证所有凭证属于同一 Session,再持久化图片。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息;prompt 凭证在同一 `rpcId` 进入 user event 或对应 queue occurrence 被移除后退休,只被命令使用的凭证则保留到 Session 销毁。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
+每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,并在持久化图片前验证每个凭证属于同一个准确 Agent。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
 
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Session 从自己的 Agent scope 取得独立的 [`fileUpload`](../../client/file-upload/README.zh.md) 服务,用于 `Blob` 与只能使用一次的 `ReadableStream<Uint8Array>` 请求体。fixture 或未绑定的 Session 会让 `Blob` 与精确字节输入继续使用生成的 Remote;stream 无法通过 base64 Remote 重放,因此必须有后台服务
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输
 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 

+ 2 - 2
packages/api/session-controller/package.json

@@ -47,8 +47,7 @@
         "@deepseek-ai/dsh-api-gateway/client"
       ],
       "inject": [
-        "@deepseek-ai/dsh-api-gateway",
-        "@deepseek-ai/dsh-client-file-upload"
+        "@deepseek-ai/dsh-api-gateway"
       ],
       "platform": "web"
     }
@@ -82,6 +81,7 @@
     "@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:^",

+ 1 - 16
packages/api/session-controller/src/client/contract/session.ts

@@ -12,7 +12,7 @@ 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 { FileUploadReceiptId, PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
+import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
 import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts'
 
 /**
@@ -89,21 +89,6 @@ export interface ISession {
     signal?: AbortSignal,
     requestId?: SessionRequestId,
   ): Promise<RemoteResult<{ accepted: true }>>
-  /**
-   * Persist one browser file verbatim and stage it for a later prompt on this
-   * session. The returned opaque receipt is what a prompt file part cites.
-   * @param data - browser Blob, exact file bytes, or a one-shot byte stream.
-   * @param name - optional display name; the host sanitizes the stored leaf name.
-   * @param signal - optional cancellation for the active upload.
-   * @param onProgress - optional byte-progress observer for background Blob or stream uploads.
-   * @returns the staged-upload receipt and durable file reference, or the business error.
-   */
-  uploadFile(
-    data: Blob | Uint8Array | ReadableStream<Uint8Array>,
-    name?: string,
-    signal?: AbortSignal,
-    onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,
-  ): Promise<RemoteResult<{ receiptId: FileUploadReceiptId; file: FileAttachmentRef }>>
   /**
    * Resolve one durable image referenced by this session.
    * @param attachmentId - opaque id found in the folded session log.

+ 1 - 95
packages/api/session-controller/src/client/sessions/session.ts

@@ -1,8 +1,7 @@
 // Sessions remain resident after creation so their open Remote sources keep running off-screen.
 
 import type { Context } from '@deepseek-ai/cordis'
-import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type { FileUploadProgress } from '@deepseek-ai/dsh-client-file-upload/client'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 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'
@@ -17,9 +16,7 @@ import type {
   SessionProjectionBaseline,
   SessionQueuedItem,
   SessionRequestId,
-  SessionUploadFileValue,
 } from '../../types.ts'
-import { SESSION_FILE_UPLOAD_PATH } from '../../file-upload-path.ts'
 import type {
   BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
 } from '../contract/session.ts'
@@ -295,57 +292,6 @@ export class Session implements SessionFace {
     return result
   }
 
-  /**
-   * Persist one browser file verbatim and stage it for a later prompt on this
-   * session (ordinary sessions only; subagent conversations refuse).
-   * @param data - exact bytes, a browser Blob, or a one-shot byte stream.
-   * @param name - optional display name; the host sanitizes the stored leaf name.
-   * @param signal - optional cancellation for the active upload.
-   * @param onProgress - optional byte-progress observer for background bodies.
-   * @returns the staged-upload receipt and durable file reference, or the business error.
-   */
-  async uploadFile(
-    data: Blob | Uint8Array | ReadableStream<Uint8Array>,
-    name?: string,
-    signal?: AbortSignal,
-    onProgress?: (progress: FileUploadProgress) => void,
-  ): Promise<RemoteResult<SessionUploadFileValue>> {
-    if (this.address !== undefined) {
-      return {
-        ok: false,
-        error: new RemoteError(
-          'subagent/attachment-invalid',
-          'subagent conversations do not accept file uploads',
-          { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
-        ),
-      }
-    }
-    if (!(data instanceof Uint8Array) && this.actx?.fileUpload.available === true) {
-      const query = new URLSearchParams({ sessionId: this.sessionId })
-      if (name !== undefined) query.set('name', name)
-      const response = await this.actx.fileUpload.post({
-        path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`,
-        body: data,
-        headers: { 'content-type': 'application/octet-stream' },
-        ...(signal === undefined ? {} : { signal }),
-        ...(onProgress === undefined ? {} : { onProgress }),
-      })
-      if (response.status !== 200) {
-        throw new Error(`file upload transport failed with HTTP ${String(response.status)}`)
-      }
-      return parseFileUploadResult(response.body)
-    }
-    if (!(data instanceof Uint8Array) && !(data instanceof Blob)) {
-      throw new Error('stream file upload requires a bound Client file-upload service')
-    }
-    const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer())
-    return this.remote.session.uploadFile({
-      sessionId: this.sessionId,
-      data: bytesToBase64(bytes),
-      ...(name === undefined ? {} : { name }),
-    }, signal)
-  }
-
   /**
    * Resolve one image referenced by this session into browser-consumable bytes.
    * @param attachmentId - opaque id found in the folded session log.
@@ -831,46 +777,6 @@ export class Session implements SessionFace {
   }
 }
 
-function parseFileUploadResult(body: string): RemoteResult<SessionUploadFileValue> {
-  const value = JSON.parse(body) as unknown
-  if (!isRecord(value) || typeof value.ok !== 'boolean') {
-    throw new TypeError('file upload transport returned an invalid result')
-  }
-  if (!value.ok) {
-    const error = value.error
-    if (!isRecord(error) || typeof error.code !== 'string'
-      || typeof error.message !== 'string' || !isRecord(error.details)) {
-      throw new TypeError('file upload transport returned an invalid failure')
-    }
-    return {
-      ok: false,
-      error: new RemoteError(error.code as never, error.message, error.details as never),
-    }
-  }
-  const result = value.value
-  const file = isRecord(result) ? result.file : undefined
-  if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file)
-    || typeof file.attachmentId !== 'string' || typeof file.name !== 'string'
-    || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
-    throw new TypeError('file upload transport returned an invalid receipt')
-  }
-  return {
-    ok: true,
-    value: {
-      receiptId: result.receiptId as SessionUploadFileValue['receiptId'],
-      file: {
-        attachmentId: file.attachmentId as SessionUploadFileValue['file']['attachmentId'],
-        name: file.name,
-        bytes: file.bytes,
-      },
-    },
-  }
-}
-
-function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
-  return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
 /** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */
 function scheduleFrame(fn: () => void): void {
   if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() })

+ 8 - 146
packages/api/session-controller/src/commands.ts

@@ -4,9 +4,10 @@ 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, admitEncodedFile, admitPromptContent } from '@deepseek-ai/dsh-attachment'
-import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
+import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
 import type { 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, freezeMessage,
 } from '@deepseek-ai/dsh-llm'
@@ -37,7 +38,6 @@ import type {
   SessionCreateValue,
   SessionForkRequest,
   SessionForkValue,
-  FileUploadReceiptId,
   SessionPromptRequest,
   SessionPromptValue,
   SessionRenameRequest,
@@ -46,8 +46,6 @@ import type {
   SessionSelectModelValue,
   SessionUpdateQueueRequest,
   SessionUpdateQueueValue,
-  SessionUploadFileRequest,
-  SessionUploadFileValue,
   SessionRequestId,
 } from './types.ts'
 
@@ -57,21 +55,8 @@ interface SessionReadState {
   readonly events: readonly SessionEvent[]
 }
 
-interface StagedFileUpload {
-  readonly file: FileAttachmentRef
-  /** Prompt that accepted this receipt; absent until successful admission. */
-  requestId?: SessionRequestId
-}
-
 /** Implements Session business commands delegated by the Session Controller Remote service. */
 export class SessionCommandController {
-  /**
-   * Staged file uploads awaiting a prompt, keyed by Session. Entries are the
-   * prompt-time authority for file references: a prompt may only cite a file
-   * previously uploaded for the same Session in this process.
-   */
-  private readonly stagedFiles = new Map<SessionId, Map<FileUploadReceiptId, StagedFileUpload>>()
-
   /**
    * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
    * @param agents - sole owner of create, resume, and Session-local model selection.
@@ -81,115 +66,7 @@ export class SessionCommandController {
     private readonly ctx: Context,
     private readonly agents: ApiSessionAgentController,
     private readonly defaultCwd: string,
-  ) {
-    ctx.inject(['commands'], (commandCtx) => {
-      const resolve: CommandFileReceiptResolver = (agent, receiptId) =>
-        this.resolveStagedFile(agent.id, receiptId as FileUploadReceiptId)
-      commandCtx.effect(
-        () => commandCtx.commands.registerFileReceiptResolver(resolve),
-        'session-controller: command file receipt resolver',
-      )
-    })
-  }
-
-  /**
-   * Persist one browser file upload verbatim and stage it for later prompts.
-   * @param request - Session identity, base64 payload, and optional display name.
-   * @returns an opaque per-upload receipt and the durable file reference.
-   */
-  async uploadFile(request: SessionUploadFileRequest): Promise<SessionUploadFileValue> {
-    const agent = await this.resolveAgent(request.sessionId)
-    return this.commitFileUpload(agent, async () => admitEncodedFile(this.ctx.attachments, {
-      data: request.data,
-      ...(request.name === undefined ? {} : { name: request.name }),
-    }))
-  }
-
-  /**
-   * Persist raw upload chunks without collecting the complete file in memory.
-   * @param request - Session identity, ordered exact bytes, cancellation, and optional display name.
-   * @returns an opaque per-upload receipt and the durable file reference.
-   */
-  async uploadFileStream(request: {
-    readonly sessionId: SessionId
-    readonly data: AsyncIterable<Uint8Array>
-    readonly signal?: AbortSignal
-    readonly name?: string
-  }): Promise<SessionUploadFileValue> {
-    const agent = await this.resolveAgent(request.sessionId)
-    return this.commitFileUpload(agent, async () => this.ctx.attachments.saveFileStream({
-      data: request.data,
-      ...(request.signal === undefined ? {} : { signal: request.signal }),
-      ...(request.name === undefined ? {} : { name: request.name }),
-    }))
-  }
-
-  private async commitFileUpload(
-    agent: Agent,
-    save: () => Promise<FileAttachmentRef>,
-  ): Promise<SessionUploadFileValue> {
-    let file: FileAttachmentRef
-    try {
-      file = await save()
-    } catch (error) {
-      if (error instanceof AttachmentError) {
-        throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
-      }
-      throw new RemoteError(
-        'gateway/internal',
-        `failed to store file upload: ${String(error)}`,
-        {},
-        { cause: error },
-      )
-    }
-    if (this.ctx.agents.get(agent.id) !== agent) {
-      throw new RemoteError(
-        'session/not-found',
-        `session "${agent.id}" was disposed before its file upload completed`,
-        { sessionId: agent.id },
-      )
-    }
-    let staged = this.stagedFiles.get(agent.id)
-    if (staged === undefined) {
-      staged = new Map()
-      this.stagedFiles.set(agent.id, staged)
-    }
-    const receiptId = randomUUID() as FileUploadReceiptId
-    staged.set(receiptId, { file })
-    return { receiptId, file }
-  }
-
-  /**
-   * Resolve one staged upload for the same Session without exposing the receipt table.
-   * @param sessionId - receiving Session identity.
-   * @param receiptId - Host-minted upload receipt.
-   * @returns the durable file reference, or `undefined` when the receipt is absent or belongs elsewhere.
-   */
-  resolveStagedFile(sessionId: SessionId, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined {
-    return this.stagedFiles.get(sessionId)?.get(receiptId)?.file
-  }
-
-  /**
-   * Retire file receipts only after their accepted prompt becomes observable.
-   * @param sessionId - Session whose log emitted the prompt.
-   * @param requestId - browser prompt identity echoed by the event.
-   */
-  retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void {
-    const staged = this.stagedFiles.get(sessionId)
-    if (staged === undefined) return
-    for (const [receiptId, upload] of staged) {
-      if (upload.requestId === requestId) staged.delete(receiptId)
-    }
-    if (staged.size === 0) this.stagedFiles.delete(sessionId)
-  }
-
-  /**
-   * Drop one Session's staged uploads (the stored objects remain durable).
-   * @param sessionId - Session leaving the live registry.
-   */
-  releaseStagedFiles(sessionId: SessionId): void {
-    this.stagedFiles.delete(sessionId)
-  }
+  ) {}
 
   /**
    * Create or idempotently adopt one ordinary Session.
@@ -451,11 +328,10 @@ export class SessionCommandController {
             )
           }
         }
-        const staged = this.stagedFiles.get(request.sessionId)
         const durable = await durablePromptContent(
           this.ctx,
           request.content,
-          receiptId => staged?.get(receiptId)?.file,
+          receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
         )
         const message: UserMessage = createUserMessage({ content: durable.content, source })
         if (this.ctx.agents.get(agent.id) !== agent) {
@@ -465,26 +341,12 @@ export class SessionCommandController {
             { sessionId: agent.id },
           )
         }
-        const bound = durable.receiptIds.map((receiptId) => {
-          const upload = staged?.get(receiptId)
-          if (upload === undefined) {
-            throw new RemoteError(
-              'session/attachment-invalid',
-              'File was not uploaded for this session.',
-              { reason: 'FILE_NOT_STAGED' },
-            )
-          }
-          return { upload, previous: upload.requestId }
-        })
-        for (const { upload } of bound) upload.requestId = request.requestId
+        const rollback = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId)
         try {
           if (request.mode === 'steer') agent.steer(message)
           else agent.followup(message)
         } catch (error) {
-          for (const { upload, previous } of bound) {
-            if (previous === undefined) delete upload.requestId
-            else upload.requestId = previous
-          }
+          rollback()
           throw error
         }
       } catch (error) {
@@ -583,7 +445,7 @@ export class SessionCommandController {
       if (request.action.kind === 'remove') {
         const source = message.source
         if (source.kind === 'user' && 'rpcId' in source) {
-          this.retireObservedPrompt(request.sessionId, source.rpcId)
+          this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
         }
       }
       if (request.action.kind === 'steer') agent.steer(message)

+ 0 - 116
packages/api/session-controller/src/file-upload-http.ts

@@ -1,116 +0,0 @@
-/** Raw Fetch file intake used by the browser background-upload carrier. */
-
-import type { Context } from '@deepseek-ai/cordis'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
-import { SESSION_FILE_UPLOAD_PATH } from './file-upload-path.ts'
-import type { SessionUploadFileValue } from './types.ts'
-import type { SessionCommandController } from './commands.ts'
-
-interface FileUploadConnection {
-  readonly fetch: {
-    register(route: {
-      readonly path: string
-      readonly methods: readonly ['POST']
-      readonly requestBody: 'streaming'
-      readonly fetch: (request: Request) => Promise<Response>
-    }): () => Promise<void>
-  }
-}
-
-type FileUploadResult =
-  | { readonly ok: true; readonly value: SessionUploadFileValue }
-  | {
-    readonly ok: false
-    readonly error: { readonly code: string; readonly message: string; readonly details: object }
-  }
-
-/**
- * Install the authenticated raw-byte route on Connection's shared Fetch registry.
- * @param ctx - Host context that provides Connection.
- * @param commands - Session command owner that validates and stages stored bytes.
- */
-export function registerSessionFileUploadHttp(ctx: Context, commands: SessionCommandController): void {
-  ctx.inject(['connection'], (connectionCtx) => {
-    const connection = connectionCtx.get('connection') as FileUploadConnection
-    connection.fetch.register({
-      path: SESSION_FILE_UPLOAD_PATH,
-      methods: ['POST'],
-      requestBody: 'streaming',
-      fetch: request => handleSessionFileUploadHttp(commands, request),
-    })
-  })
-}
-
-/**
- * Handle one authenticated raw-byte upload after the physical carrier applies
- * its trust policy.
- * @param commands - Session command owner that validates and stages stored bytes.
- * @param request - Fetch request carrying the raw file body.
- * @returns JSON receipt or a precise validation response.
- */
-export async function handleSessionFileUploadHttp(
-  commands: SessionCommandController,
-  request: Request,
-): Promise<Response> {
-  if (request.method !== 'POST') {
-    return new Response(null, { status: 405, headers: { allow: 'POST' } })
-  }
-  const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
-  if (mediaType !== 'application/octet-stream') {
-    return new Response('content type must be application/octet-stream', { status: 415 })
-  }
-  const url = new URL(request.url)
-  const sessionId = url.searchParams.get('sessionId')
-  if (sessionId === null || sessionId === '') {
-    return new Response('sessionId is required', { status: 400 })
-  }
-  const name = url.searchParams.get('name') ?? undefined
-  let result: FileUploadResult
-  try {
-    result = {
-      ok: true,
-      value: await commands.uploadFileStream({
-        sessionId: SessionId(sessionId),
-        data: requestBodyChunks(request.body),
-        signal: request.signal,
-        ...(name === undefined ? {} : { name }),
-      }),
-    }
-  } catch (error) {
-    const failure = remoteErrorOf(error)
-    result = {
-      ok: false,
-      error: failure !== undefined
-        ? { code: failure.code, message: failure.message, details: failure.details }
-        : {
-          code: 'gateway/internal',
-          message: error instanceof Error ? error.message : String(error),
-          details: {},
-        },
-    }
-  }
-  return new Response(JSON.stringify(result), {
-    status: 200,
-    headers: {
-      'content-type': 'application/json; charset=utf-8',
-      'cache-control': 'no-store',
-    },
-  })
-}
-
-async function* requestBodyChunks(
-  body: ReadableStream<Uint8Array> | null,
-): AsyncIterable<Uint8Array> {
-  if (body === null) return
-  const reader = body.getReader()
-  try {
-    while (true) {
-      const chunk = await reader.read()
-      if (chunk.done) return
-      yield chunk.value
-    }
-  } finally {
-    reader.releaseLock()
-  }
-}

+ 0 - 2
packages/api/session-controller/src/file-upload-path.ts

@@ -1,2 +0,0 @@
-/** Authenticated raw-byte upload route shared by Host and Client faces. */
-export const SESSION_FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'

+ 7 - 22
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'
@@ -17,7 +18,6 @@ import { SessionCommandController } from './commands.ts'
 import { SessionControlController } from './control.ts'
 import { SessionHistoryController } from './history.ts'
 import { SessionFileReferences } from './file-references.ts'
-import { registerSessionFileUploadHttp } from './file-upload-http.ts'
 import {
   ApiSessionList,
   DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
@@ -55,8 +55,6 @@ import type {
   SessionSelectModelValue,
   SessionUpdateQueueRequest,
   SessionUpdateQueueValue,
-  SessionUploadFileRequest,
-  SessionUploadFileValue,
 } from './types.ts'
 
 export type * from './types.ts'
@@ -95,6 +93,7 @@ export class SessionController extends TypertRemoteService {
     'agentDefaultModel',
     'agents',
     'attachments',
+    'fileUploads',
     'llm',
     'sessions',
     'sessionProjections',
@@ -128,7 +127,11 @@ export class SessionController extends TypertRemoteService {
     installModelSelectionProjection(ctx)
     this.agents = new ApiSessionAgentController(ctx)
     this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
-    registerSessionFileUploadHttp(ctx, this.commands)
+    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.
@@ -150,7 +153,6 @@ export class SessionController extends TypertRemoteService {
       ctx.emit('api-session/added', this.listState.summaryFor(session))
     })
     ctx.on('session/disposed', (session) => {
-      this.commands.releaseStagedFiles(session.id)
       ctx.emit('api-session/removed', session.id)
     })
     ctx.on('agent/status', ({ agent, status }) => {
@@ -160,10 +162,6 @@ export class SessionController extends TypertRemoteService {
       ctx.emit('api-session/error', agent.id, errorChain(error))
     })
     ctx.on('session/event', (session, event) => {
-      if (event.type === 'user/message' && event.data.source.kind === 'user'
-        && 'rpcId' in event.data.source) {
-        this.commands.retireObservedPrompt(session.id, event.data.source.rpcId)
-      }
       if (event.type === 'request/header') {
         const agent = ctx.agents.get(session.id)
         if (agent?.session === session) this.agents.consumeSelection(
@@ -356,19 +354,6 @@ export class SessionController extends TypertRemoteService {
     return this.commands.attachment(request)
   }
 
-  /**
-   * Persist one encoded file upload verbatim and stage it for a later prompt
-   * on the same Session.
-   * @param request - Session identity, base64 payload, and optional display name.
-   * @param signal - caller cancellation before storage begins.
-   * @returns an opaque per-upload receipt and the durable file reference.
-   */
-  @Remote('uploadFile')
-  uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue> {
-    signal.throwIfAborted()
-    return this.commands.uploadFile(request)
-  }
-
   /**
    * Mutate one still-pending queue occurrence on a live Agent.
    * @param request - Session, queue item, and requested mutation.

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

@@ -1,7 +1,7 @@
 /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
 
 import type {
-  AttachmentIdType, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
+  AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
 } from '@deepseek-ai/dsh-attachment'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -81,7 +81,7 @@ export type PromptContentPart =
     readonly data: string
     readonly name?: string
   }
-  | { readonly type: 'file'; readonly receiptId: FileUploadReceiptId }
+  | { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> }
 
 /** Complete model selection for one Session. */
 export interface ModelSelection {
@@ -318,22 +318,6 @@ export interface SessionPromptValue {
   readonly accepted: true
 }
 
-/** One base64 file upload staged for a later prompt on the same Session. */
-export interface SessionUploadFileRequest {
-  readonly sessionId: SessionId
-  /** Canonical base64 encoding of the exact file bytes. */
-  readonly data: string
-  /** Optional display name; the Host sanitizes it into the stored leaf name. */
-  readonly name?: string
-}
-
-/** Durable receipt for one staged file upload. */
-export interface SessionUploadFileValue {
-  /** Per-upload authority consumed by a later prompt on the same Session. */
-  readonly receiptId: FileUploadReceiptId
-  readonly file: FileAttachmentRef
-}
-
 /** Durable image read request. */
 export interface SessionAttachmentRequest {
   readonly sessionId: SessionId
@@ -382,9 +366,6 @@ export interface SessionOpenWorkspacePathValue {
 /** Client-minted prompt identity used to reconcile optimistic and durable messages. */
 export type SessionRequestId = Branded<'session-request-id'>
 
-/** Host-minted authority for one staged file upload on one Session. */
-export type FileUploadReceiptId = Branded<'file-upload-receipt-id'>
-
 declare module '@deepseek-ai/dsh-llm' {
   interface MessageSourceMap {
     /** Browser prompt correlation and optional Host-validated time zone. */

+ 122 - 76
packages/api/session-controller/tests/commands-upload-file.host.spec.ts

@@ -9,16 +9,20 @@ 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 { FileUploadReceiptId, SessionRequestId } from '../src/types.ts'
+import type { SessionRequestId } from '../src/types.ts'
 
 const SESSION = SessionId('upload-session')
 
-async function uploadHarness(): Promise<{
+async function uploadHarness(origin?: 'subagent'): Promise<{
   ctx: Context
   controller: SessionCommandController
+  uploads: FileUploads
   agent: Agent
   followup: ReturnType<typeof vi.fn>
   saveFile: ReturnType<typeof vi.fn>
@@ -30,7 +34,9 @@ async function uploadHarness(): Promise<{
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(CommandRuntime)
-  const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace' } })
+  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 = {
@@ -38,11 +44,12 @@ async function uploadHarness(): Promise<{
     session,
     inbox,
     status: 'idle',
-    ctx,
+    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)}`),
@@ -61,6 +68,7 @@ async function uploadHarness(): Promise<{
   const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
     Promise.reject(new Error('fixture did not expect image persistence')))
   ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never)
+  ctx.provide('connection', { fetch: { register: () => async () => {} } } as never)
   ctx.provide('llm', {
     listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
     resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
@@ -74,9 +82,11 @@ async function uploadHarness(): Promise<{
     selectionFor: () => selection,
     serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
   } as unknown as ApiSessionAgentController
+  const uploads = new FileUploads(ctx)
   return {
     ctx,
     controller: new SessionCommandController(ctx, agents, '/workspace'),
+    uploads,
     agent,
     followup,
     saveFile,
@@ -97,14 +107,13 @@ function promptRequest(content: Parameters<SessionCommandController['prompt']>[0
 
 describe('Session file uploads', () => {
   it('stages one verbatim upload and cites it from a later prompt as a file block', async () => {
-    const { ctx, controller, agent, followup, saveFile } = await uploadHarness()
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'notes.pdf' })
+    const { ctx, controller, uploads, agent, followup, saveFile } = 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(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
-    expect(controller.resolveStagedFile(SessionId('other-session'), receipt.receiptId)).toBeUndefined()
-    expect(controller.resolveStagedFile(SESSION, 'missing' as FileUploadReceiptId)).toBeUndefined()
+    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 },
@@ -132,9 +141,9 @@ describe('Session file uploads', () => {
   })
 
   it('stages a bounded byte stream and forwards cancellation to storage', async () => {
-    const { controller, followup, saveFileStream } = await uploadHarness()
+    const { controller, uploads, followup, saveFileStream } = await uploadHarness()
     const abort = new AbortController()
-    const receipt = await controller.uploadFileStream({
+    const receipt = await uploads.uploadStream({
       sessionId: SESSION,
       data: (async function* (): AsyncIterable<Uint8Array> {
         yield Uint8Array.of(1, 2)
@@ -155,13 +164,13 @@ describe('Session file uploads', () => {
   })
 
   it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
-    const { controller, saveFileStream } = await uploadHarness()
+    const { uploads, saveFileStream } = await uploadHarness()
     const stream = (async function* (): AsyncIterable<Uint8Array> {})()
-    await expect(controller.uploadFileStream({ sessionId: SESSION, data: stream }))
+    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(controller.uploadFileStream({
+    await expect(uploads.uploadStream({
       sessionId: SESSION,
       data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
     }))
@@ -170,26 +179,6 @@ describe('Session file uploads', () => {
       })
   })
 
-  it('unregisters its command receipt resolver with the owning plugin fiber', async () => {
-    const ctx = new Context()
-    const commands = ctx.plugin(CommandRuntime)
-    await commands.await()
-    const agents = {} as ApiSessionAgentController
-    const plugin = {
-      apply(ownerCtx: Context) {
-        new SessionCommandController(ownerCtx, agents, '/workspace')
-      },
-    }
-
-    const first = ctx.plugin(plugin)
-    await first.await()
-    await first.dispose()
-    const replacement = ctx.plugin(plugin)
-    await replacement.await()
-    await replacement.dispose()
-    await commands.dispose()
-  })
-
   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([
@@ -201,10 +190,10 @@ describe('Session file uploads', () => {
   })
 
   it('publishes no receipt when its exact Agent is disposed during storage', async () => {
-    const { controller, saveFile, disposeAgent } = await uploadHarness()
+    const { uploads, agent, saveFile, disposeAgent } = await uploadHarness()
     const saved = Promise.withResolvers<FileAttachmentRef>()
     saveFile.mockReturnValueOnce(saved.promise)
-    const uploading = controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'late.bin' })
+    const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
     await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
     disposeAgent()
     saved.resolve({
@@ -213,16 +202,84 @@ describe('Session file uploads', () => {
     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)
+    expect(disposeReplacement).toBeTypeOf('function')
+    disposeReplacement()
+  })
+
+  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 } = await uploadHarness()
-    controller.retireObservedPrompt(SESSION, 'not-staged' as SessionRequestId)
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    const { controller, uploads, agent } = await uploadHarness()
+    uploads.retirePrompt(agent, 'not-staged' as SessionRequestId)
+    const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
     await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
-    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
-    controller.retireObservedPrompt(SESSION, 'other-request' as SessionRequestId)
-    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
-    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
-    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined()
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
+    uploads.retirePrompt(agent, 'other-request' as SessionRequestId)
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
+    uploads.retirePrompt(agent, 'req-1' as SessionRequestId)
+    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.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 () => {
@@ -267,8 +324,8 @@ describe('Session file uploads', () => {
   })
 
   it('rejects when a previously bound receipt retires during image admission', async () => {
-    const { controller, saveImages } = await uploadHarness()
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    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)
@@ -280,7 +337,7 @@ describe('Session file uploads', () => {
       requestId: 'req-2' as SessionRequestId,
     })
     await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
-    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
+    uploads.retirePrompt(agent, 'req-1' as SessionRequestId)
     admitted.resolve([{
       attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
     }])
@@ -290,32 +347,32 @@ describe('Session file uploads', () => {
   })
 
   it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
-    const { controller, followup } = await uploadHarness()
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    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' })
-    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
-    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined()
+    uploads.retirePrompt(agent, 'req-1' as SessionRequestId)
+    expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
   })
 
   it('restores an unbound receipt after prompt delivery fails', async () => {
-    const { controller, followup } = await uploadHarness()
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    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' })
-    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
-    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
+    uploads.retirePrompt(agent, 'req-1' as SessionRequestId)
+    expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
   })
 
   it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
-    const { controller, agent, followup } = await uploadHarness()
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    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)
@@ -324,24 +381,13 @@ describe('Session file uploads', () => {
       itemId: queued.id,
       action: { kind: 'remove' },
     })).toEqual({ accepted: true })
-    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined()
-  })
-
-  it('drops staged uploads with the session while the stored object stays durable', async () => {
-    const { controller, followup } = await uploadHarness()
-    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
-    expect(receipt.file.name).toBe('file')
-    controller.releaseStagedFiles(SESSION)
-    await expect(controller.prompt(promptRequest([
-      { type: 'file', receiptId: receipt.receiptId },
-    ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
-    expect(followup).not.toHaveBeenCalled()
+    expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
   })
 
   it('keeps separate names for identical bytes uploaded more than once', async () => {
-    const { controller, followup } = await uploadHarness()
-    const first = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'first.txt' })
-    const second = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'second.txt' })
+    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)
 
@@ -358,16 +404,16 @@ describe('Session file uploads', () => {
   })
 
   it('maps a non-canonical payload to the wire attachment error', async () => {
-    const { controller, saveFile } = await uploadHarness()
-    await expect(controller.uploadFile({ sessionId: SESSION, data: 'not base64!!' }))
+    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 { controller, saveFile } = await uploadHarness()
+    const { uploads, agent, saveFile } = await uploadHarness()
     saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
-    await expect(controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }))
+    await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal))
       .rejects.toMatchObject({
         code: 'gateway/internal',
         message: 'failed to store file upload: Error: disk unavailable',

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

@@ -8,7 +8,6 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { describe, expect, it, vi } from 'vitest'
 import SessionController from '../src/index.ts'
 import type { ApiSessionAgentController } from '../src/agent.ts'
-import type { SessionRequestId } from '../src/types.ts'
 import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
 
 const defaults = {
@@ -44,21 +43,6 @@ describe('SessionController facade', () => {
       inspect,
     }) as never)
     const controller = createSessionTestController(ctx, defaults)
-    const uploadRequest = { sessionId, data: 'AAAA', name: 'notes.txt' }
-    const uploadValue = {
-      receiptId: 'receipt-1' as never,
-      file: {
-        attachmentId: `sha256:${'ab'.repeat(32)}` as never,
-        name: 'notes.txt',
-        bytes: 3,
-      },
-    }
-    const uploadFile = vi.spyOn((controller as unknown as {
-      commands: { uploadFile(request: typeof uploadRequest): Promise<typeof uploadValue> }
-    }).commands, 'uploadFile').mockResolvedValue(uploadValue)
-    await expect(controller.uploadFile(uploadRequest, new AbortController().signal))
-      .resolves.toBe(uploadValue)
-    expect(uploadFile).toHaveBeenCalledWith(uploadRequest)
     const status = vi.fn()
     const failure = vi.fn()
     const activity = vi.fn()
@@ -85,9 +69,6 @@ describe('SessionController facade', () => {
       (controller as unknown as { agents: ApiSessionAgentController }).agents,
       'consumeSelection',
     )
-    const retireObservedPrompt = vi.spyOn((controller as unknown as {
-      commands: { retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void }
-    }).commands, 'retireObservedPrompt')
 
     await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
     await expect(controller.inspect(sessionId)).resolves.toEqual({
@@ -109,7 +90,6 @@ describe('SessionController facade', () => {
     expect(status).toHaveBeenCalledWith(sessionId, true)
     expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
     expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
-    expect(retireObservedPrompt).toHaveBeenCalledWith(sessionId, 'controller-rpc')
     session.append('request/header', {
       header: { config: { provider: 'fixture', model: 'fixture-model' } },
       reason: 'initial',

+ 0 - 8
packages/api/session-controller/tests/fake-api.client.ts

@@ -143,13 +143,6 @@ export class FakeApiClient {
   onPrompt: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onAttachment: (payload: unknown) => Promise<RemoteResult<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
     () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
-  onUploadFile: (payload: unknown) => Promise<RemoteResult<{
-    receiptId: never
-    file: { attachmentId: never; name: string; bytes: number }
-  }>> = () => Promise.resolve(ok({
-    receiptId: 'r' as never,
-    file: { attachmentId: 'f' as never, name: 'file', bytes: 1 },
-  }))
   onUpdateQueue: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onCancel: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
@@ -232,7 +225,6 @@ export class FakeApiClient {
         fork: payload => this.record('session.fork', payload, this.onFork(payload)),
         prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)),
         attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)),
-        uploadFile: payload => this.record('session.uploadFile', payload, this.onUploadFile(payload)),
         updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
         cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),
         openWorkspacePath: payload => this.record(

+ 30 - 48
packages/api/session-controller/tests/file-upload-http.host.spec.ts

@@ -1,12 +1,8 @@
 import { runInNewContext } from 'node:vm'
-import { Context } from '@deepseek-ai/cordis'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { describe, expect, it, vi } from 'vitest'
 import type { Mock } from 'vitest'
-import type { SessionCommandController } from '../src/commands.ts'
-import {
-  handleSessionFileUploadHttp, registerSessionFileUploadHttp,
-} from '../src/file-upload-http.ts'
+import { FileUploads, handleFileUploadHttp } from '@deepseek-ai/dsh-client-file-upload'
 
 function request(input: {
   method?: string
@@ -26,63 +22,49 @@ function request(input: {
   })
 }
 
-function commands(result: unknown): SessionCommandController & {
-  uploadFileStream: Mock<SessionCommandController['uploadFileStream']>
+function uploads(result: unknown): FileUploads & {
+  uploadStream: Mock<FileUploads['uploadStream']>
   uploadedChunks: Uint8Array[]
 } {
   const uploadedChunks: Uint8Array[] = []
-  const uploadFileStream = vi.fn<SessionCommandController['uploadFileStream']>(async (input) => {
+  const uploadStream = vi.fn<FileUploads['uploadStream']>(async (input) => {
     for await (const chunk of input.data) uploadedChunks.push(chunk)
-    return await result as Awaited<ReturnType<SessionCommandController['uploadFileStream']>>
+    return await result as Awaited<ReturnType<FileUploads['uploadStream']>>
   })
   return {
     uploadedChunks,
-    uploadFileStream,
-  } as unknown as SessionCommandController & {
-    uploadFileStream: Mock<SessionCommandController['uploadFileStream']>
+    uploadStream,
+  } as unknown as FileUploads & {
+    uploadStream: Mock<FileUploads['uploadStream']>
     uploadedChunks: Uint8Array[]
   }
 }
 
 describe('background file upload Fetch route', () => {
-  it('registers one authenticated POST route on Connection', async () => {
-    const ctx = new Context()
-    const register = vi.fn((_route: unknown) => vi.fn(async () => {}))
-    ctx.provide('connection', { fetch: { register } } as never)
-    registerSessionFileUploadHttp(ctx, commands(Promise.resolve({})))
-    await vi.waitFor(() => { expect(register).toHaveBeenCalledOnce() })
-    const route = register.mock.calls[0]?.[0] as {
-      path: string
-      methods: string[]
-      requestBody: string
-      fetch(request: Request): Promise<Response>
-    }
-    expect(route.path).toBe('/api/session/uploadFileBinary')
-    expect(route.methods).toEqual(['POST'])
-    expect(route.requestBody).toBe('streaming')
-    expect((await route.fetch(request({
+  it('accepts one authenticated streaming POST request', async () => {
+    const service = uploads(Promise.resolve({}))
+    expect((await handleFileUploadHttp(service, request({
       sessionId: 's1', contentType: 'application/octet-stream',
     }))).status).toBe(200)
-    await ctx.fiber.dispose()
   })
 
   it('rejects the wrong method, media type, and missing Session id without storing', async () => {
-    const controller = commands(Promise.resolve({}))
-    const wrongMethod = await handleSessionFileUploadHttp(controller, request({ method: 'GET' }))
+    const service = uploads(Promise.resolve({}))
+    const wrongMethod = await handleFileUploadHttp(service, request({ method: 'GET' }))
     expect(wrongMethod.status).toBe(405)
     expect(wrongMethod.headers.get('allow')).toBe('POST')
 
-    const wrongType = await handleSessionFileUploadHttp(controller, request({ contentType: 'application/json' }))
+    const wrongType = await handleFileUploadHttp(service, request({ contentType: 'application/json' }))
     expect(wrongType.status).toBe(415)
     expect(await wrongType.text()).toBe('content type must be application/octet-stream')
 
-    const missingSession = await handleSessionFileUploadHttp(
-      controller,
+    const missingSession = await handleFileUploadHttp(
+      service,
       request({ contentType: 'application/octet-stream' }),
     )
     expect(missingSession.status).toBe(400)
     expect(await missingSession.text()).toBe('sessionId is required')
-    expect(controller.uploadFileStream).not.toHaveBeenCalled()
+    expect(service.uploadStream).not.toHaveBeenCalled()
   })
 
   it('stores the request bytes and returns the staged receipt', async () => {
@@ -90,18 +72,18 @@ describe('background file upload Fetch route', () => {
       receiptId: 'receipt-1',
       file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 },
     }
-    const controller = commands(Promise.resolve(value))
-    const response = await handleSessionFileUploadHttp(controller, request({
+    const service = uploads(Promise.resolve(value))
+    const response = await handleFileUploadHttp(service, request({
       sessionId: 's1',
       name: 'large & final.bin',
       contentType: 'application/octet-stream; charset=binary',
       body: Uint8Array.of(1, 2, 3, 4),
     }))
-    expect(controller.uploadFileStream).toHaveBeenCalledOnce()
-    const upload = controller.uploadFileStream.mock.calls[0]?.[0]
+    expect(service.uploadStream).toHaveBeenCalledOnce()
+    const upload = service.uploadStream.mock.calls[0]?.[0]
     expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' })
     expect(upload?.signal).toBeInstanceOf(AbortSignal)
-    expect(controller.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)])
+    expect(service.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)])
     expect(response.status).toBe(200)
     expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8')
     expect(response.headers.get('cache-control')).toBe('no-store')
@@ -109,14 +91,14 @@ describe('background file upload Fetch route', () => {
   })
 
   it('returns business and internal storage failures and keeps an absent name absent', async () => {
-    const business = commands(Promise.reject(new RemoteError(
+    const business = uploads(Promise.reject(new RemoteError(
       'session/attachment-invalid', 'denied', { reason: 'NOPE' },
     )))
-    const businessResponse = await handleSessionFileUploadHttp(business, request({
+    const businessResponse = await handleFileUploadHttp(business, request({
       sessionId: 's1', contentType: 'application/octet-stream',
     }))
-    expect(business.uploadFileStream).toHaveBeenCalledOnce()
-    const upload = business.uploadFileStream.mock.calls[0]?.[0]
+    expect(business.uploadStream).toHaveBeenCalledOnce()
+    const upload = business.uploadStream.mock.calls[0]?.[0]
     expect(upload).toMatchObject({ sessionId: 's1' })
     expect(upload?.signal).toBeInstanceOf(AbortSignal)
     expect(business.uploadedChunks).toEqual([])
@@ -125,16 +107,16 @@ describe('background file upload Fetch route', () => {
       error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
     })
 
-    const internal = commands(Promise.reject(new Error('disk offline')))
-    expect(await (await handleSessionFileUploadHttp(internal, request({
+    const internal = uploads(Promise.reject(new Error('disk offline')))
+    expect(await (await handleFileUploadHttp(internal, request({
       sessionId: 's1', contentType: 'application/octet-stream',
     }))).json()).toEqual({
       ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} },
     })
 
     const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error
-    const exception = commands(Promise.reject(foreignError))
-    expect(await (await handleSessionFileUploadHttp(exception, request({
+    const exception = uploads(Promise.reject(foreignError))
+    expect(await (await handleFileUploadHttp(exception, request({
       sessionId: 's1', contentType: 'application/octet-stream',
     }))).json()).toEqual({
       ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} },

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

@@ -1,8 +1,6 @@
 /** Session object lifecycle, event-window transport, commands, and resync behavior. */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import type { FileUploadService } from '@deepseek-ai/dsh-client-file-upload/client'
 import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
@@ -17,7 +15,6 @@ const PARENT = 'fk-parent' as SessionId
 afterEach(() => {
   vi.unstubAllGlobals()
 })
-
 function makeSession(
   api = new FakeApiClient(),
   options: SessionOptions = {},
@@ -25,16 +22,6 @@ function makeSession(
   return { api, session: new Session(SID, fakeRemote(api), options) }
 }
 
-function bindFileUpload(
-  session: Session,
-  post: FileUploadService['post'],
-  available = true,
-): void {
-  const ctx = new Context()
-  ctx.reflect.provide('fileUpload', { available, post })
-  session.bindScope(ctx)
-}
-
 function follow(
   api: FakeApiClient,
   event: SessionEvent,
@@ -57,177 +44,6 @@ function histResponse(events: SessionEvent[], hasMore = false) {
   return Promise.resolve(ok(historyValue(events, hasMore)))
 }
 
-describe('Session file upload', () => {
-  it('uses the background body carrier, reports progress, and validates its receipt', async () => {
-    const progress = vi.fn()
-    const post = vi.fn(async (request: {
-      path: string
-      body: Blob | ReadableStream<Uint8Array>
-      headers?: Readonly<Record<string, string>>
-      signal?: AbortSignal
-      onProgress?: (progress: { loaded: number; total?: number }) => void
-    }) => {
-      request.onProgress?.({ loaded: 2, total: 4 })
-      return {
-        status: 200,
-        body: JSON.stringify({
-          ok: true,
-          value: {
-            receiptId: 'receipt-1',
-            file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
-          },
-        }),
-      }
-    })
-    const { api, session } = makeSession()
-    bindFileUpload(session, post)
-    const abort = new AbortController()
-    const file = new Blob([Uint8Array.of(1, 2, 3, 4)])
-
-    await expect(session.uploadFile(file, 'notes & refs.pdf', abort.signal, progress)).resolves.toEqual({
-      ok: true,
-      value: {
-        receiptId: 'receipt-1',
-        file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
-      },
-    })
-    expect(post).toHaveBeenCalledWith(expect.objectContaining({
-      path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=notes+%26+refs.pdf',
-      body: file,
-      headers: { 'content-type': 'application/octet-stream' },
-      signal: abort.signal,
-    }))
-    expect(progress).toHaveBeenCalledWith({ loaded: 2, total: 4 })
-    expect(api.callsOf('session.uploadFile')).toEqual([])
-  })
-
-  it('preserves a background business failure and supports unnamed files without observers', async () => {
-    const post = vi.fn<FileUploadService['post']>(() => Promise.resolve({
-      status: 200,
-      body: JSON.stringify({
-        ok: false,
-        error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
-      }),
-    }))
-    const { session } = makeSession()
-    bindFileUpload(session, post)
-    await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
-      ok: false,
-      error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
-    })
-    const request = post.mock.calls[0]?.[0]
-    expect(request).toMatchObject({
-      path: '/api/session/uploadFileBinary?sessionId=fk-s1',
-      headers: { 'content-type': 'application/octet-stream' },
-    })
-    expect(request?.body).toBeInstanceOf(Blob)
-  })
-
-  it('keeps byte and Blob fallback uploads on the generated Remote carrier', async () => {
-    const { api, session } = makeSession()
-    await expect(session.uploadFile(Uint8Array.of(0, 0, 0), 'bytes.bin')).resolves.toMatchObject({ ok: true })
-    await expect(session.uploadFile(new Blob([Uint8Array.of(1)]))).resolves.toMatchObject({ ok: true })
-    expect(api.callsOf('session.uploadFile')).toEqual([
-      { sessionId: SID, data: 'AAAA', name: 'bytes.bin' },
-      { sessionId: SID, data: 'AQ==' },
-    ])
-  })
-
-  it('keeps fixture Blob fallback on the Remote and refuses an uncarried stream', async () => {
-    const { api, session } = makeSession()
-    const post = vi.fn<FileUploadService['post']>()
-    bindFileUpload(session, post, false)
-
-    await expect(session.uploadFile(new Blob([Uint8Array.of(1)]), 'fixture.bin'))
-      .resolves.toMatchObject({ ok: true })
-    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
-    await expect(session.uploadFile(stream, 'stream.bin'))
-      .rejects.toThrow('stream file upload requires a bound Client file-upload service')
-    expect(post).not.toHaveBeenCalled()
-    expect(api.callsOf('session.uploadFile')).toEqual([
-      { sessionId: SID, data: 'AQ==', name: 'fixture.bin' },
-    ])
-  })
-
-  it('hands a one-shot ReadableStream to the scoped file-upload service', async () => {
-    const post = vi.fn(() => Promise.resolve({
-      status: 200,
-      body: JSON.stringify({
-        ok: true,
-        value: {
-          receiptId: 'stream-receipt',
-          file: { attachmentId: 'stream-file', name: 'stream.bin', bytes: 3 },
-        },
-      }),
-    }))
-    const { session } = makeSession()
-    bindFileUpload(session, post)
-    const stream = new ReadableStream<Uint8Array>({
-      start(controller) {
-        controller.enqueue(Uint8Array.of(1, 2, 3))
-        controller.close()
-      },
-    })
-    await expect(session.uploadFile(stream, 'stream.bin')).resolves.toMatchObject({ ok: true })
-    expect(post).toHaveBeenCalledWith(expect.objectContaining({
-      path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=stream.bin',
-      body: stream,
-    }))
-  })
-
-  it('refuses a stream when a bare Session has no scoped upload service', async () => {
-    const { session } = makeSession()
-    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
-    await expect(session.uploadFile(stream)).rejects.toThrow(
-      'stream file upload requires a bound Client file-upload service',
-    )
-  })
-
-  it('folds non-200 and malformed background responses into transport failures', async () => {
-    const bodies: unknown[] = [
-      null,
-      { ok: 'yes' },
-      { ok: false, error: null },
-      { ok: false, error: { code: 1, message: 'x', details: {} } },
-      { ok: false, error: { code: 'x', message: 1, details: {} } },
-      { ok: false, error: { code: 'x', message: 'x', details: null } },
-      { ok: true, value: null },
-      { ok: true, value: { receiptId: 1, file: {} } },
-      { ok: true, value: { receiptId: 'r', file: null } },
-      { ok: true, value: { receiptId: 'r', file: { attachmentId: 1, name: 'x', bytes: 1 } } },
-      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 1, bytes: 1 } } },
-      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: '1' } } },
-      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: 1.5 } } },
-      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
-    ]
-    for (const body of bodies) {
-      const { session } = makeSession()
-      bindFileUpload(session, () => Promise.resolve({ status: 200, body: JSON.stringify(body) }))
-      await expect(session.uploadFile(new Blob([])))
-        .rejects.toThrow(/file upload transport returned an invalid/)
-    }
-    const { session } = makeSession()
-    bindFileUpload(session, () => Promise.resolve({ status: 503, body: 'unavailable' }))
-    await expect(session.uploadFile(new Blob([])))
-      .rejects.toThrow('file upload transport failed with HTTP 503')
-  })
-
-  it('refuses a direct file upload for a continuable subagent before either carrier runs', async () => {
-    const post = vi.fn()
-    const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api), {
-      address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
-    })
-    bindFileUpload(session, post)
-    await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
-      ok: false,
-      error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } },
-    })
-    expect(post).not.toHaveBeenCalled()
-    expect(api.callsOf('session.uploadFile')).toEqual([])
-  })
-})
-
 describe('Session open', () => {
   it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => {
     const { session } = makeSession()

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

@@ -236,6 +236,14 @@ function installControllers(
       },
     } as never)
   }
+  if (ctx.get('fileUploads') === undefined) {
+    ctx.provide('fileUploads', {
+      registerAgentResolver: () => () => {},
+      resolve: () => undefined,
+      bindPrompt: () => () => {},
+      retirePrompt: () => {},
+    } as never)
+  }
   installSessionReadTestServices(ctx)
   const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
   let controller: SessionController

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

@@ -7,7 +7,6 @@
   },
   "include": [
     "src/client/**/*.ts",
-    "src/file-upload-path.ts",
     "src/types.ts",
     "src/remote-events.ts"
   ],
@@ -16,7 +15,7 @@
     { "path": "../gateway/tsconfig.client.json" },
     { "path": "../../attachment/attachment" },
     { "path": "../../client/connection/tsconfig.client.json" },
-    { "path": "../../client/file-upload" },
+    { "path": "../../client/file-upload/tsconfig.client.json" },
     { "path": "../../client/store" },
     { "path": "../../context/file-reference" },
     { "path": "../../core/session" },

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

@@ -14,8 +14,6 @@
     "src/commands.ts",
     "src/control.ts",
     "src/file-references.ts",
-    "src/file-upload-path.ts",
-    "src/file-upload-http.ts",
     "src/history.ts",
     "src/list.ts",
     "src/model-selection-projection.ts",
@@ -30,6 +28,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/client/file-upload/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/file-upload/README.md
-README.md: 9abd4106dbd7cd1cb9671f8f830952f6faff11c6
-README.zh.md: 4ad14ca5a91acd482f54cab74fb974208a264a5a
+README.md: be7f923fc7769bb337597a53a82b22be73866b8f
+README.zh.md: fb355562c1249f85585cef4cbbaaa674a8ced690

+ 12 - 8
packages/client/file-upload/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Background browser uploads for raw Blob and ReadableStream bodies, including worker transfer, progress, and cancellation."
+description: "Agent-scoped browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package lets browser features upload a `Blob` or `ReadableStream<Uint8Array>` without aggregating its bytes on the page thread. Served pages send each body through a dedicated Worker; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page reports the background carrier as unavailable so its Session adapter can keep using the generated in-memory Remote.
+This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream<Uint8Array>` under one Agent scope and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs.
 
 ## Table of Contents
 
@@ -25,14 +25,14 @@ This package lets browser features upload a `Blob` or `ReadableStream<Uint8Array
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount the Client plugin before a consumer that injects `fileUpload`, then call `ctx.fileUpload.post()` with a same-origin path and one raw body.
+Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`. The service derives the wire identity from `agentContext`; callers do not assemble an upload URL or Remote request.
 
 ```yaml
 - id: file-upload
   name: '@deepseek-ai/dsh-client-file-upload'
 ```
 
-The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier.
+The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the scoped generated Remote.
 
 -----
 
@@ -42,17 +42,21 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Client plugin provides one inherited `ctx.fileUpload` service. Its provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Consumers inspect `ctx.fileUpload.available` before selecting it. Without a hook, each non-fixture request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
+The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
+
+The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission.
 
 | File | Role |
 |---|---|
-| [`src/client/contract.ts`](src/client/contract.ts) | Cordis service request, response, progress, and page-hook types |
+| [`src/index.ts`](src/index.ts) | Host streaming route, storage, and Agent-scoped receipt lifecycle |
+| [`src/types.ts`](src/types.ts) | encoded request, receipt, and durable result types |
+| [`src/client/contract.ts`](src/client/contract.ts) | Client upload, progress, and page-hook types |
 | [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations |
 | [`src/client/index.ts`](src/client/index.ts) | Client plugin registration and `ctx.fileUpload` declaration |
 
 </details>
 
-**Runtime invariant:** No companion is published. Each request uses exactly one selected carrier, and fixture or unsupported-browser requests fail before the body is sent.
+**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Agent scope, and each request uses one selected carrier. Unscoped calls and unsupported stream carriers fail before the body is sent.
 
 -----
 
@@ -60,7 +64,7 @@ The Client plugin provides one inherited `ctx.fileUpload` service. Its provider
 ## Further Exploration
 
 - [Connection](../connection/README.md) — authenticated RPC, exact Host routes, and connection generations.
-- [Session Controller](../../api/session-controller/README.md) — the raw file-upload route and staged Session receipt.
+- [Session Controller](../../api/session-controller/README.md) — prompt admission that consumes staged receipts.
 - [Web Worker runtime](../../experimental/webworker-runtime/README.md) — the page-to-Host Worker request tunnel.
 - [Client group map](../README.md) — browser services and UI feature packages.
 

+ 12 - 8
packages/client/file-upload/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "在浏览器后台上传原始 Blob 与 ReadableStream,包括 Worker 转交、进度和取消。"
+description: "按 Agent scope 上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包让浏览器功能上传 `Blob` 或 `ReadableStream<Uint8Array>`,同时避免在页面线程聚合全部字节。普通服务页面通过专用 Worker 发送每个请求体;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面会报告后台载体不可用,让 Session adapter 继续使用生成的内存 Remote
+本包让浏览器功能在一个 Agent scope 下存储 `Blob`、精确字节或 `ReadableStream<Uint8Array>`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入
 
 ## 目录
 
@@ -25,14 +25,14 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-在注入 `fileUpload` 的消费方之前挂载 Client 插件,再用同源路径和一个原始请求体调用 `ctx.fileUpload.post()`
+在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`。服务从 `agentContext` 得到 wire identity;调用方不组装上传 URL 或 Remote 请求
 
 ```yaml
 - id: file-upload
   name: '@deepseek-ai/dsh-client-file-upload'
 ```
 
-本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。
+本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用按 scope 寻址的生成 Remote。
 
 -----
 
@@ -42,17 +42,21 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。消费者在选择服务前检查 `ctx.fileUpload.available`。没有该钩子时,每个非 fixture 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
+Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
+
+Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。
 
 | 文件 | 职责 |
 |---|---|
-| [`src/client/contract.ts`](src/client/contract.ts) | Cordis 服务的请求、响应、进度与页面钩子类型 |
+| [`src/index.ts`](src/index.ts) | Host 流式路由、存储与按 Agent scope 管理的凭证生命周期 |
+| [`src/types.ts`](src/types.ts) | 编码请求、凭证与持久结果类型 |
+| [`src/client/contract.ts`](src/client/contract.ts) | Client 上传、进度与页面钩子类型 |
 | [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 |
 | [`src/client/index.ts`](src/client/index.ts) | Client 插件注册与 `ctx.fileUpload` 声明 |
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。每个请求只使用一个已选定载体;fixture 或不支持的浏览器请求会在发送请求体前失败。
+**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Agent scope,每个请求只使用一个已选定载体。没有 scope 的调用和载体不支持的 stream 会在发送请求体前失败。
 
 -----
 
@@ -60,7 +64,7 @@ Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其
 ## 进一步探索
 
 - [Connection](../connection/README.zh.md)——认证 RPC、Host 精确路由与 connection generation。
-- [Session Controller](../../api/session-controller/README.zh.md)——原始文件上传路由与按 Session 暂存的凭证
+- [Session Controller](../../api/session-controller/README.zh.md)——消费暂存凭证的 prompt 准入
 - [Web Worker runtime](../../experimental/webworker-runtime/README.zh.md)——页面到 Host Worker 的请求隧道。
 - [客户端组地图](../README.zh.md)——浏览器服务与 UI 功能包。
 

+ 42 - 5
packages/client/file-upload/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-file-upload",
-  "description": "Browser background upload service for Blob and ReadableStream request bodies",
+  "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
   "version": "0.1.2-rc.1",
   "publishConfig": {
     "access": "public"
@@ -22,12 +22,26 @@
       "types": "./lib/types/client/index.d.ts",
       "default": "./lib/client.js"
     },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "dsh": {
     "client": {
-      "inject": [],
+      "inject": [
+        "@deepseek-ai/dsh-api-remotes"
+      ],
       "platform": "web",
       "immediately": true
     }
@@ -40,12 +54,35 @@
   "files": [
     "lib/index.js",
     "lib/client.js",
-    "lib/types/**/*.d.ts"
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
   ],
   "peerDependencies": {
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
   },
   "devDependencies": {
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
   }
 }

+ 19 - 21
packages/client/file-upload/src/client/contract.ts

@@ -7,32 +7,29 @@ export interface FileUploadProgress {
   readonly total?: number
 }
 
-/** One background upload request. */
-export interface FileUploadRequest {
-  readonly path: string
-  readonly body: FileUploadBody
-  readonly headers?: Readonly<Record<string, string>>
-  readonly signal?: AbortSignal
-  readonly onProgress?: (progress: FileUploadProgress) => void
-}
-
-/** Small response returned after the service has sent the body. */
-export interface FileUploadResponse {
-  readonly status: number
-  readonly body: string
-}
-
-/** Browser upload service inherited by every Client Cordis scope. */
+/** Browser upload service addressed through one Client Agent scope. */
 export interface FileUploadService {
   /** Whether this page has a Host-backed background upload carrier. */
   readonly available: boolean
   /**
-   * Post one Blob or one-shot byte stream without aggregating it on the page thread.
-   * The call consumes a stream body and rejects with `AbortError` when its signal fires.
-   * @param request - target, body, cancellation, and progress observer.
-   * @returns the response status and text body.
+   * Store one file under an Agent scope. Blob and stream bodies use
+   * the background carrier; exact bytes and fixture fallbacks use Remote.
+   * @param owner - Agent-scoped Client context that owns the staged receipt.
+   * @param data - browser Blob, exact bytes, or a one-shot byte stream.
+   * @param name - optional display name.
+   * @param signal - optional cancellation for the active upload.
+   * @param onProgress - optional byte-progress observer for background bodies.
+   * @returns the staged receipt and durable file reference, or a business error.
    */
-  post(request: FileUploadRequest): Promise<FileUploadResponse>
+  upload(
+    owner: Context,
+    data: Blob | Uint8Array | ReadableStream<Uint8Array>,
+    name?: string,
+    signal?: AbortSignal,
+    onProgress?: (progress: FileUploadProgress) => void,
+  ): Promise<import('@deepseek-ai/dsh-typert-protocol').RemoteResult<
+    import('../types.ts').FileUploadValue
+  >>
 }
 
 /**
@@ -47,3 +44,4 @@ export type FileUploadFetch = (input: URL, init: RequestInit) => Promise<Respons
 export interface ClientFileUploadHooks {
   readonly fetch: FileUploadFetch
 }
+import type { Context } from '@deepseek-ai/cordis'

+ 4 - 5
packages/client/file-upload/src/client/index.ts

@@ -8,20 +8,19 @@ export type {
   FileUploadBody,
   FileUploadFetch,
   FileUploadProgress,
-  FileUploadRequest,
-  FileUploadResponse,
   FileUploadService,
 } from './contract.ts'
+export type { FileUploadReceiptId, FileUploadValue } from '../types.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
-    /** Browser service for non-aggregating Blob and byte-stream uploads. */
+    /** Agent-scoped browser service for staged file uploads. */
     fileUpload: import('./contract.ts').FileUploadService
   }
 }
 
-/** This transport service has no Cordis dependencies. */
-export const inject: string[] = []
+/** The upload service resolves Agent identity and the generated Remote fallback. */
+export const inject = ['typert', 'remote']
 
 /**
  * Provide the browser background-upload service.

+ 124 - 2
packages/client/file-upload/src/client/runtime.ts

@@ -1,15 +1,46 @@
 /** Background browser upload implementation for Blob and byte-stream bodies. */
 
 import { Service, type Context } from '@deepseek-ai/cordis'
+import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import type { RemoteResult, TypertContextRegistry } from '@deepseek-ai/dsh-typert-protocol'
+import { FILE_UPLOAD_PATH } from '../protocol.ts'
+import type { FileUploadValue } from '../types.ts'
 import type {
   ClientFileUploadHooks,
   FileUploadBody,
   FileUploadFetch,
-  FileUploadRequest,
-  FileUploadResponse,
   FileUploadService,
 } from './contract.ts'
 
+interface FileUploadRequest {
+  readonly path: string
+  readonly body: FileUploadBody
+  readonly headers?: Readonly<Record<string, string>>
+  readonly signal?: AbortSignal
+  readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void
+}
+
+interface FileUploadResponse {
+  readonly status: number
+  readonly body: string
+}
+
+interface AgentFileUploadContext extends Context {
+  readonly remote: {
+    readonly fileUploads: {
+      upload(
+        request: import('../types.ts').EncodedFileUploadRequest,
+        signal?: AbortSignal,
+      ): Promise<RemoteResult<FileUploadValue>>
+    }
+  }
+}
+
+interface FileUploadTypert {
+  readonly contexts: Pick<TypertContextRegistry, 'getClient'>
+}
+
 interface UploadWorkerStart {
   readonly url: string
   readonly body: FileUploadBody
@@ -154,6 +185,57 @@ export class FileUploadRuntime extends Service implements FileUploadService {
     if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode'))
     return this.transport.post(request)
   }
+
+  /**
+   * Store one file under an Agent scope.
+   * @param owner - Agent-scoped Client context that owns the staged receipt.
+   * @param data - browser Blob, exact bytes, or a one-shot byte stream.
+   * @param name - optional display name.
+   * @param signal - optional cancellation for the active upload.
+   * @param onProgress - optional byte-progress observer for background bodies.
+   * @returns the staged receipt and durable file reference, or a business error.
+   */
+  async upload(
+    owner: Context,
+    data: Blob | Uint8Array | ReadableStream<Uint8Array>,
+    name?: string,
+    signal?: AbortSignal,
+    onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,
+  ): Promise<RemoteResult<FileUploadValue>> {
+    const sessionId = this.sessionId(owner)
+    if (!(data instanceof Uint8Array) && this.available) {
+      const query = new URLSearchParams({ sessionId })
+      if (name !== undefined) query.set('name', name)
+      const response = await this.post({
+        path: `${FILE_UPLOAD_PATH}?${query.toString()}`,
+        body: data,
+        headers: { 'content-type': 'application/octet-stream' },
+        ...(signal === undefined ? {} : { signal }),
+        ...(onProgress === undefined ? {} : { onProgress }),
+      })
+      if (response.status !== 200) {
+        throw new Error(`file upload transport failed with HTTP ${String(response.status)}`)
+      }
+      return parseFileUploadResult(response.body)
+    }
+    if (!(data instanceof Uint8Array) && !(data instanceof Blob)) {
+      throw new Error('stream file upload requires a background carrier')
+    }
+    const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer())
+    return (owner as AgentFileUploadContext).remote.fileUploads.upload({
+      data: bytesToBase64(bytes),
+      ...(name === undefined ? {} : { name }),
+    }, signal)
+  }
+
+  private sessionId(owner: Context): string {
+    const typert = this.ctx.get('typert') as FileUploadTypert | undefined
+    const sessionId = typert?.contexts.getClient('agent')?.identity(owner)
+    if (typeof sessionId !== 'string' || sessionId === '') {
+      throw new Error('fileUpload.upload requires an Agent-scoped context')
+    }
+    return sessionId
+  }
 }
 
 function customTransport(customFetch: FileUploadFetch): FileUploadTransport {
@@ -246,3 +328,43 @@ function isFixturePage(): boolean {
     && 'search' in pageLocation && typeof pageLocation.search === 'string'
     && new URLSearchParams(pageLocation.search).has('fixture')
 }
+
+function parseFileUploadResult(body: string): RemoteResult<FileUploadValue> {
+  const value = JSON.parse(body) as unknown
+  if (!isRecord(value) || typeof value.ok !== 'boolean') {
+    throw new TypeError('file upload transport returned an invalid result')
+  }
+  if (!value.ok) {
+    const error = value.error
+    if (!isRecord(error) || typeof error.code !== 'string'
+      || typeof error.message !== 'string' || !isRecord(error.details)) {
+      throw new TypeError('file upload transport returned an invalid failure')
+    }
+    return {
+      ok: false,
+      error: new RemoteError(error.code as never, error.message, error.details as never),
+    }
+  }
+  const result = value.value
+  const file = isRecord(result) ? result.file : undefined
+  if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file)
+    || typeof file.attachmentId !== 'string' || typeof file.name !== 'string'
+    || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
+    throw new TypeError('file upload transport returned an invalid receipt')
+  }
+  return {
+    ok: true,
+    value: {
+      receiptId: result.receiptId as FileUploadValue['receiptId'],
+      file: {
+        attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'],
+        name: file.name,
+        bytes: file.bytes,
+      },
+    },
+  }
+}
+
+function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}

+ 313 - 3
packages/client/file-upload/src/index.ts

@@ -1,4 +1,314 @@
-/** Host half of the browser-only file-upload service. */
+/** Host file-upload service: streamed intake and Agent-scoped staged receipts. */
 
-/** No Host behavior; the Client half provides the browser upload service. */
-export function apply(): void {}
+import { randomUUID } from 'node:crypto'
+import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { AttachmentError, admitEncodedFile } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
+import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
+import { scopeOf } from '@deepseek-ai/dsh-scope'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import { FILE_UPLOAD_PATH } from './protocol.ts'
+import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts'
+
+export { FILE_UPLOAD_PATH } from './protocol.ts'
+export type * from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Host storage and staged-receipt service for browser file uploads. */
+    fileUploads: FileUploads
+  }
+}
+
+interface StagedFileUpload {
+  readonly file: FileAttachmentRef
+  /** Prompt that accepted this receipt; absent until successful admission. */
+  requestId?: string
+}
+
+/** Resolve or resume the ordinary Agent that owns one Session identity. */
+export type AgentResolver = (sessionId: SessionId) => Promise<Agent>
+
+type FileUploadHttpResult =
+  | { readonly ok: true; readonly value: FileUploadValue }
+  | {
+    readonly ok: false
+    readonly error: { readonly code: string; readonly message: string; readonly details: object }
+  }
+
+/** Host service owning upload storage and Agent-scoped staged receipts. */
+export class FileUploads extends TypertRemoteService {
+  static inject = ['agents', 'attachments', 'commands', 'connection']
+
+  private readonly stagedFiles = new WeakMap<Session, Map<FileUploadReceiptId, StagedFileUpload>>()
+  private agentResolver: AgentResolver | undefined
+
+  /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */
+  constructor(ctx: Context) {
+    super(ctx, 'fileUploads')
+    const resolve: CommandFileReceiptResolver = (agent, receiptId) =>
+      this.resolve(agent, receiptId as FileUploadReceiptId)
+    ctx.effect(
+      () => ctx.commands.registerFileReceiptResolver(resolve),
+      'file-upload: command file receipt resolver',
+    )
+    ctx.effect(
+      () => (ctx.connection as HostConnectionHandle).fetch.register({
+        path: FILE_UPLOAD_PATH,
+        methods: ['POST'],
+        requestBody: 'streaming',
+        fetch: request => handleFileUploadHttp(this, request),
+      }),
+      'file-upload: streaming route',
+    )
+    ctx.on('session/event', (session, event) => { this.observeSessionEvent(session, event) })
+    ctx.on('session/disposed', (session) => { this.stagedFiles.delete(session) })
+  }
+
+  /**
+   * 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 {
+    if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered')
+    this.agentResolver = resolve
+    return () => {
+      if (this.agentResolver === resolve) this.agentResolver = undefined
+    }
+  }
+
+  /**
+   * 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> {
+    signal.throwIfAborted()
+    return this.commit(agent, async () => admitEncodedFile(this.ctx.attachments, {
+      data: request.data,
+      ...(request.name === undefined ? {} : { name: request.name }),
+    }))
+  }
+
+  /**
+   * 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> {
+    const agent = await this.resolveAgent(request.sessionId)
+    return this.commit(agent, async () => this.ctx.attachments.saveFileStream({
+      data: request.data,
+      ...(request.signal === undefined ? {} : { signal: request.signal }),
+      ...(request.name === undefined ? {} : { name: request.name }),
+    }))
+  }
+
+  /**
+   * 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 {
+    this.assertAgentScope(agent)
+    return this.stagedFiles.get(agent.session)?.get(receiptId)?.file
+  }
+
+  /**
+   * Bind receipts to an accepted prompt and return a rollback for delivery failure.
+   * @param agent - receiving Agent.
+   * @param receiptIds - distinct staged receipts referenced by the prompt.
+   * @param requestId - prompt identity later observed in queue or history.
+   * @returns rollback restoring every prior binding.
+   */
+  bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void {
+    this.assertAgentScope(agent)
+    const staged = this.stagedFiles.get(agent.session)
+    const bound = receiptIds.map((receiptId) => {
+      const upload = staged?.get(receiptId)
+      if (upload === undefined) throw fileNotStaged()
+      return { upload, previous: upload.requestId }
+    })
+    for (const { upload } of bound) upload.requestId = requestId
+    return () => {
+      for (const { upload, previous } of bound) {
+        if (previous === undefined) delete upload.requestId
+        else upload.requestId = previous
+      }
+    }
+  }
+
+  /**
+   * 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 {
+    this.assertAgentScope(agent)
+    this.retire(agent.session, requestId)
+  }
+
+  private async commit(agent: Agent, save: () => Promise<FileAttachmentRef>): Promise<FileUploadValue> {
+    this.assertOrdinaryAgent(agent)
+    let file: FileAttachmentRef
+    try {
+      file = await save()
+    } catch (error) {
+      if (error instanceof AttachmentError) {
+        throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never)
+      }
+      throw new RemoteError(
+        'gateway/internal',
+        `failed to store file upload: ${String(error)}`,
+        {},
+        { cause: error },
+      )
+    }
+    if (this.ctx.agents.get(agent.id) !== agent) {
+      throw new RemoteError(
+        'session/not-found',
+        `session "${agent.id}" was disposed before its file upload completed`,
+        { sessionId: agent.id },
+      )
+    }
+    let staged = this.stagedFiles.get(agent.session)
+    if (staged === undefined) {
+      staged = new Map()
+      this.stagedFiles.set(agent.session, staged)
+    }
+    const receiptId = randomUUID() as FileUploadReceiptId
+    staged.set(receiptId, { file })
+    return { receiptId, file }
+  }
+
+  private async resolveAgent(sessionId: SessionId): Promise<Agent> {
+    const live = this.ctx.agents.get(sessionId)
+    if (live !== undefined) return live
+    const resolver = this.agentResolver
+    if (resolver === undefined) {
+      throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId })
+    }
+    return resolver(sessionId)
+  }
+
+  private assertAgentScope(agent: Agent): void {
+    if (scopeOf(agent.ctx) !== agent) throw new Error('file-upload: operation requires the Agent\'s own scope')
+  }
+
+  private assertOrdinaryAgent(agent: Agent): void {
+    this.assertAgentScope(agent)
+    if (agent.session.header.origin === 'subagent') {
+      throw new RemoteError(
+        'subagent/attachment-invalid' as never,
+        'subagent conversations do not accept file uploads',
+        { reason: 'SUBAGENT_FILE_UNSUPPORTED' } as never,
+      )
+    }
+  }
+
+  private observeSessionEvent(session: Session, event: SessionEvent): void {
+    if (event.type !== 'user/message' || event.data.source.kind !== 'user'
+      || !('rpcId' in event.data.source)) return
+    if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId)
+  }
+
+  private retire(session: Session, requestId: string): void {
+    const staged = this.stagedFiles.get(session)
+    if (staged === undefined) return
+    for (const [receiptId, upload] of staged) {
+      if (upload.requestId === requestId) staged.delete(receiptId)
+    }
+    if (staged.size === 0) this.stagedFiles.delete(session)
+  }
+}
+
+/**
+ * Handle one authenticated raw-byte upload.
+ * @param service - Host upload service receiving streamed bytes.
+ * @param request - authenticated HTTP request from Connection.
+ * @returns JSON result using HTTP status 200 after request validation.
+ */
+export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise<Response> {
+  if (request.method !== 'POST') {
+    return new Response(null, { status: 405, headers: { allow: 'POST' } })
+  }
+  const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
+  if (mediaType !== 'application/octet-stream') {
+    return new Response('content type must be application/octet-stream', { status: 415 })
+  }
+  const url = new URL(request.url)
+  const sessionId = url.searchParams.get('sessionId')
+  if (sessionId === null || sessionId === '') {
+    return new Response('sessionId is required', { status: 400 })
+  }
+  const name = url.searchParams.get('name') ?? undefined
+  let result: FileUploadHttpResult
+  try {
+    result = {
+      ok: true,
+      value: await service.uploadStream({
+        sessionId: SessionId(sessionId),
+        data: requestBodyChunks(request.body),
+        signal: request.signal,
+        ...(name === undefined ? {} : { name }),
+      }),
+    }
+  } catch (error) {
+    const failure = remoteErrorOf(error)
+    result = {
+      ok: false,
+      error: failure !== undefined
+        ? { code: failure.code, message: failure.message, details: failure.details }
+        : {
+          code: 'gateway/internal',
+          message: error instanceof Error ? error.message : String(error),
+          details: {},
+        },
+    }
+  }
+  return new Response(JSON.stringify(result), {
+    status: 200,
+    headers: {
+      'content-type': 'application/json; charset=utf-8',
+      'cache-control': 'no-store',
+    },
+  })
+}
+
+async function* requestBodyChunks(body: ReadableStream<Uint8Array> | null): AsyncIterable<Uint8Array> {
+  if (body === null) return
+  const reader = body.getReader()
+  try {
+    while (true) {
+      const chunk = await reader.read()
+      if (chunk.done) return
+      yield chunk.value
+    }
+  } finally {
+    reader.releaseLock()
+  }
+}
+
+function fileNotStaged(): RemoteError {
+  return new RemoteError(
+    'session/attachment-invalid' as never,
+    'File was not uploaded for this session.',
+    { reason: 'FILE_NOT_STAGED' } as never,
+  )
+}
+
+export default FileUploads

+ 2 - 0
packages/client/file-upload/src/protocol.ts

@@ -0,0 +1,2 @@
+/** Authenticated raw-byte route owned by the file-upload service. */
+export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'

+ 22 - 0
packages/client/file-upload/src/types.ts

@@ -0,0 +1,22 @@
+/** Browser-safe request and receipt types for staged file uploads. */
+
+import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** Canonical encoded upload accepted by the Remote fallback. */
+export interface EncodedFileUploadRequest {
+  /** Canonical base64 encoding of the exact file bytes. */
+  readonly data: string
+  /** Optional display name; the Host sanitizes it into the stored leaf name. */
+  readonly name?: string
+}
+
+/** Durable receipt for one staged file upload. */
+export interface FileUploadValue {
+  /** Per-upload authority accepted only inside the receiving Agent scope. */
+  readonly receiptId: FileUploadReceiptId
+  readonly file: FileAttachmentRef
+}
+
+/** Host-minted authority for one staged file upload in one Agent scope. */
+export type FileUploadReceiptId = Branded<'file-upload-receipt-id'>

+ 135 - 17
packages/client/file-upload/tests/file-upload.client.spec.ts

@@ -1,6 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { apply as applyHost } from '../src/index.ts'
 import { apply } from '../src/client/index.ts'
 import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
 import type { ClientFileUploadHooks, FileUploadBody } from '../src/client/contract.ts'
@@ -211,10 +210,6 @@ describe('file upload worker body', () => {
 })
 
 describe('file upload service', () => {
-  it('keeps the Host half inert', () => {
-    expect(() => { applyHost() }).not.toThrow()
-  })
-
   it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => {
     vi.stubGlobal('location', { origin: 'https://preview.test' })
     const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
@@ -225,7 +220,7 @@ describe('file upload service', () => {
     await fiber
     const blob = new Blob(['opaque'])
     const signal = new AbortController().signal
-    await expect(ctx.fileUpload.post({
+    await expect((ctx.fileUpload as FileUploadRuntime).post({
       path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
     })).resolves.toEqual({ status: 202, body: 'accepted' })
     expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), {
@@ -233,7 +228,7 @@ describe('file upload service', () => {
     })
 
     const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
-    await ctx.fileUpload.post({ path: '/stream', body: stream })
+    await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
     expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), {
       method: 'POST', body: stream, duplex: 'half',
     })
@@ -248,7 +243,7 @@ describe('file upload service', () => {
     const fiber = ctx.plugin({ apply })
     await fiber
     const body = new Blob()
-    await ctx.fileUpload.post({ path: '/fallback', body })
+    await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body })
     expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), {
       method: 'POST', body,
     })
@@ -261,7 +256,7 @@ describe('file upload service', () => {
     const fiber = ctx.plugin(FileUploadRuntime)
     await fiber
     expect(ctx.fileUpload.available).toBe(false)
-    await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() }))
+    await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
       .rejects.toThrow('background upload is unavailable in fixture mode')
     await fiber.dispose()
   })
@@ -271,7 +266,7 @@ describe('file upload service', () => {
     const ctx = new Context()
     const fiber = ctx.plugin(FileUploadRuntime)
     await fiber
-    await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() }))
+    await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
       .rejects.toThrow('background upload requires Web Worker support')
     await fiber.dispose()
   })
@@ -294,7 +289,7 @@ describe('file upload service', () => {
     await fiber
     const progress = vi.fn()
     const blob = new Blob(['bytes'])
-    const pending = ctx.fileUpload.post({ path: '/api/upload', body: blob, onProgress: progress })
+    const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress })
     const worker = FakeWorker.last
     if (worker === undefined) throw new Error('worker missing')
     expect(created).toHaveBeenCalledOnce()
@@ -331,7 +326,7 @@ describe('file upload service', () => {
     const fiber = ctx.plugin(FileUploadRuntime)
     await fiber
     const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
-    const pending = ctx.fileUpload.post({ path: '/stream', body: stream })
+    const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
     const worker = FakeWorker.last
     if (worker === undefined) throw new Error('worker missing')
     expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream])
@@ -356,29 +351,152 @@ describe('file upload service', () => {
     const fiber = ctx.plugin(FileUploadRuntime)
     await fiber
 
-    const reported = ctx.fileUpload.post({ path: '/upload', body: new Blob() })
+    const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
     FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
     await expect(reported).rejects.toThrow('network failed')
 
-    const errored = ctx.fileUpload.post({ path: '/upload', body: new Blob() })
+    const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
     FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
     await expect(errored).rejects.toThrow('worker crashed')
 
-    const unnamed = ctx.fileUpload.post({ path: '/upload', body: new Blob() })
+    const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
     FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
     await expect(unnamed).rejects.toThrow('background upload worker failed')
 
     const controller = new AbortController()
-    const aborted = ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: controller.signal })
+    const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal })
     controller.abort()
     await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
     expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
 
     const already = new AbortController()
     already.abort()
-    await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: already.signal }))
+    await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal }))
       .rejects.toMatchObject({ name: 'AbortError' })
     expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
     await fiber.dispose()
   })
 })
+
+describe('Agent-scoped file upload', () => {
+  async function scopedService(options: {
+    readonly sessionId?: string
+    readonly remote?: ReturnType<typeof vi.fn>
+  } = {}) {
+    const ctx = new Context()
+    ctx.provide('sessions', {
+      scopeOf: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined,
+    } as never)
+    ctx.provide('typert', {
+      contexts: {
+        getClient: (kind: string) => kind === 'agent'
+          ? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') }
+          : undefined,
+      },
+    } as never)
+    const remote = options.remote ?? vi.fn(() => Promise.resolve({
+      ok: true,
+      value: {
+        receiptId: 'remote-receipt',
+        file: { attachmentId: 'remote-file', name: 'file', bytes: 3 },
+      },
+    }))
+    ctx.provide('remote', { fileUploads: { upload: remote } } as never)
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+    const owner = options.sessionId === undefined
+      ? ctx
+      : ctx.extend({ fixtureSessionId: options.sessionId })
+    return { ctx, fiber, owner, remote, service: ctx.fileUpload }
+  }
+
+  it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
+    vi.stubGlobal('location', { origin: 'https://preview.test' })
+    const progress = vi.fn()
+    const fetch = vi.fn((_url: URL, init: RequestInit) => {
+      expect(init.body).toBeInstanceOf(Blob)
+      progress({ loaded: 2, total: 4 })
+      return Promise.resolve(new Response(JSON.stringify({
+        ok: true,
+        value: {
+          receiptId: 'receipt-1',
+          file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
+        },
+      }), { status: 200 }))
+    })
+    ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
+    const { fiber, owner, service } = await scopedService({ sessionId: 's1' })
+    const signal = new AbortController().signal
+    const file = new Blob(['data'])
+
+    await expect(service.upload(owner, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
+      ok: true,
+      value: {
+        receiptId: 'receipt-1',
+        file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
+      },
+    })
+    expect(fetch).toHaveBeenCalledWith(
+      new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'),
+      expect.objectContaining({
+        method: 'POST',
+        headers: { 'content-type': 'application/octet-stream' },
+        body: file,
+        signal,
+      }),
+    )
+    await fiber.dispose()
+  })
+
+  it('uses the scoped Remote fallback for exact bytes and fixture Blob bodies', async () => {
+    vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
+    const remote = vi.fn(() => Promise.resolve({
+      ok: true,
+      value: {
+        receiptId: 'remote-receipt',
+        file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 },
+      },
+    }))
+    const { fiber, owner, service } = await scopedService({ sessionId: 's1', remote })
+    await expect(service.upload(owner, Uint8Array.of(0, 0, 0), 'bytes.bin'))
+      .resolves.toMatchObject({ ok: true })
+    await expect(service.upload(owner, new Blob([Uint8Array.of(1)])))
+      .resolves.toMatchObject({ ok: true })
+    expect(remote.mock.calls).toEqual([
+      [{ data: 'AAAA', name: 'bytes.bin' }, undefined],
+      [{ data: 'AQ==' }, undefined],
+    ])
+    await fiber.dispose()
+  })
+
+  it('rejects an unscoped call, an unavailable stream, and malformed background results', async () => {
+    vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
+    const unscoped = await scopedService()
+    await expect(unscoped.service.upload(unscoped.owner, Uint8Array.of(1)))
+      .rejects.toThrow('fileUpload.upload requires an Agent-scoped context')
+    await unscoped.fiber.dispose()
+
+    const fixture = await scopedService({ sessionId: 's1' })
+    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+    await expect(fixture.service.upload(fixture.owner, stream))
+      .rejects.toThrow('stream file upload requires a background carrier')
+    await fixture.fiber.dispose()
+
+    vi.stubGlobal('location', { origin: 'https://preview.test' })
+    const bodies: unknown[] = [
+      null,
+      { ok: 'yes' },
+      { ok: false, error: null },
+      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
+    ]
+    for (const body of bodies) {
+      ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
+        fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
+      }
+      const malformed = await scopedService({ sessionId: 's1' })
+      await expect(malformed.service.upload(malformed.owner, new Blob()))
+        .rejects.toThrow(/file upload transport returned an invalid/)
+      await malformed.fiber.dispose()
+    }
+  })
+})

+ 22 - 0
packages/client/file-upload/tsconfig.client.json

@@ -0,0 +1,22 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+  },
+  "files": [
+    "src/client/contract.ts",
+    "src/client/index.ts",
+    "src/client/runtime.ts",
+    "src/protocol.ts",
+    "src/types.ts"
+  ],
+  "references": [
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../util/brand" },
+    { "path": "../../util/crypto" },
+    { "path": "../../attachment/attachment" },
+    { "path": "../../typert/protocol" }
+  ]
+}

+ 20 - 0
packages/client/file-upload/tsconfig.host.json

@@ -0,0 +1,20 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+  },
+  "files": ["src/index.ts", "src/protocol.ts", "src/types.ts"],
+  "references": [
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../util/brand" },
+    { "path": "../../core/agent" },
+    { "path": "../../core/scope" },
+    { "path": "../../core/session" },
+    { "path": "../../attachment/attachment" },
+    { "path": "../../interaction/commands" },
+    { "path": "../../typert/protocol" },
+    { "path": "../connection/tsconfig.host.json" }
+  ]
+}

+ 3 - 11
packages/client/file-upload/tsconfig.json

@@ -1,15 +1,7 @@
 {
-  "extends": "../../../tsconfig.base.client.json",
-  "compilerOptions": {
-    "rootDir": "src",
-    "outDir": "lib/types"
-  },
-  "include": [
-    "src"
-  ],
+  "files": [],
   "references": [
-    {
-      "path": "../../../vendor/cordis"
-    }
+    { "path": "./tsconfig.host.json" },
+    { "path": "./tsconfig.client.json" }
   ]
 }

+ 2 - 1
packages/client/ui-chat/src/client/conversation-nodes/compaction.ts

@@ -1,8 +1,9 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {
-  CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
+  ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-compaction/types'
+import type { CompactionSummaryNode } from '../contract/snapshot.ts'
 import { chatNode } from './common.ts'
 import { compactSource, compactSummary, updateCompactionState } from './command.ts'
 

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

@@ -29,6 +29,7 @@
     "client": {
       "inject": [
         "@deepseek-ai/dsh-api-session-controller",
+        "@deepseek-ai/dsh-client-file-upload",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-renderer",
@@ -64,6 +65,7 @@
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",

+ 6 - 9
packages/client/ui-conversation/src/client/apply.ts

@@ -44,7 +44,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 
 /** Services required by the Conversation plugin. */
 export const inject = [
-  'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope',
+  'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope',
 ]
 
 /** Conversation runtime configuration. */
@@ -238,11 +238,10 @@ export function apply(ctx: Context, config: Config = Config({})): void {
           const attachmentIds = from.snapshot.attachmentIds
           const next = inputHub.shell(nextId)
           if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) {
-            const target = sessions.binding(nextId)?.session
-            if (target === undefined) {
+            if (sessions.binding(nextId) === undefined) {
               throw new Error(`ui-conversation: session "${nextId}" resolved no binding`)
             }
-            concreteConversation(ctx).rebindDraftFiles(target, attachmentIds)
+            concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds)
             if (draft !== '') {
               next.setDraft(draft)
               from.setDraft('')
@@ -331,10 +330,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       return {
         keyboard: shell,
         addFiles: (files) => {
-          const session = sessions.binding(sessionId)?.session
-          if (session === undefined) return t('file.sessionUnavailable')
+          if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable')
           try {
-            const drafts = conversation.createDrafts(session, files)
+            const drafts = conversation.createDrafts(sessionId, files)
             if (!shell.addAttachments(drafts.map(draft => draft.id))) {
               conversation.releaseDraftAttachments(drafts)
             }
@@ -349,8 +347,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
         },
         resolveDraftAttachments: ids => conversation.resolveDraftAttachments(ids),
         retryFileUpload: (id) => {
-          const session = sessions.binding(sessionId)?.session
-          if (session !== undefined) conversation.retryFileUpload(session, id)
+          if (sessions.binding(sessionId) !== undefined) conversation.retryFileUpload(sessionId, id)
         },
         resolveSubmitMode: (running, gesture, steeringAvailable) =>
           submissionPolicy.resolve(running, gesture, steeringAvailable),

+ 1 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -2,7 +2,7 @@
 import type { ReactNode, RefObject } from 'react'
 import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { FileUploadReceiptId } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type {
   MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook,

+ 15 - 11
packages/client/ui-conversation/src/client/service.ts

@@ -16,6 +16,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type {
   ISessions, PendingSubmissionRetirement, SessionFace,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import type {} from '@deepseek-ai/dsh-client-file-upload/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -300,11 +301,11 @@ export class ConversationController extends Service implements IConversation {
    * with the prompt); every other file becomes a file draft whose background
    * upload starts immediately and remains owned by this service across Session
    * navigation until completion or explicit removal.
-   * @param session - target session owning staged file uploads.
+   * @param sessionId - target Agent-scope identity.
    * @param files - browser files to register.
    * @returns ordered draft descriptors.
    */
-  createDrafts(session: SessionFace, files: readonly File[]): readonly ComposerAttachment[] {
+  createDrafts(sessionId: SessionId, files: readonly File[]): readonly ComposerAttachment[] {
     return files.map((file) => {
       if (isImageMediaType(file.type)) {
         const attachment = browserDraftAttachment(file)
@@ -318,36 +319,36 @@ export class ConversationController extends Service implements IConversation {
         file,
       }
       this.draftAttachments.set(attachment.id, attachment)
-      this.beginFileUpload(session, attachment)
+      this.beginFileUpload(sessionId, attachment)
       return attachment
     })
   }
 
   /**
    * Restart one failed file upload.
-   * @param session - target session owning staged file uploads.
+   * @param sessionId - target Agent-scope identity.
    * @param id - draft attachment id whose upload previously failed.
    */
-  retryFileUpload(session: SessionFace, id: DraftAttachmentId): void {
+  retryFileUpload(sessionId: SessionId, id: DraftAttachmentId): void {
     const attachment = this.draftAttachments.get(id)
     if (attachment === undefined || attachment.kind !== 'file') return
     if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return
-    this.beginFileUpload(session, attachment)
+    this.beginFileUpload(sessionId, attachment)
   }
 
   /**
    * Stage carried file drafts again for a new Session.
-   * @param session - target Session after a Workspace switch.
+   * @param sessionId - target Agent-scope identity after a Workspace switch.
    * @param ids - carried draft attachment ids.
    */
-  rebindDraftFiles(session: SessionFace, ids: readonly DraftAttachmentId[]): void {
+  rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void {
     for (const id of ids) {
       const attachment = this.draftAttachments.get(id)
-      if (attachment?.kind === 'file') this.beginFileUpload(session, attachment)
+      if (attachment?.kind === 'file') this.beginFileUpload(sessionId, attachment)
     }
   }
 
-  private beginFileUpload(session: SessionFace, attachment: ComposerFileAttachment): void {
+  private beginFileUpload(sessionId: SessionId, attachment: ComposerFileAttachment): void {
     this.fileUploadOperations.get(attachment.id)?.controller.abort()
     const controller = new AbortController()
     this.fileUploads.update((draft) => {
@@ -362,7 +363,10 @@ export class ConversationController extends Service implements IConversation {
       try {
         if (controller.signal.aborted
           || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
-        const result = await session.uploadFile(
+        const owner = this.requireSessions().scope(sessionId)
+        if (owner === undefined) throw new Error(`conversation: session "${sessionId}" resolved no Agent scope`)
+        const result = await this.ctx.fileUpload.upload(
+          owner,
           attachment.file,
           attachment.file.name === '' ? undefined : attachment.file.name,
           controller.signal,

+ 23 - 11
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -28,18 +29,28 @@ function sessionFakeFor() {
     loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
     prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
     cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
-    uploadFile: vi.fn<ISession['uploadFile']>(() => Promise.resolve({
-      ok: true,
-      value: {
-        receiptId: 'root-receipt' as never,
-        file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 },
-      },
-    })),
   } satisfies SessionBehaviorOverrides
 }
 
 async function bench() {
   const runtime = await SlotTestRuntime.create()
+  const rootUpload = vi.fn(() => Promise.resolve({
+    ok: true as const,
+    value: {
+      receiptId: 'root-receipt' as never,
+      file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 },
+    },
+  }))
+  const uploads = new Map<SessionId, (...args: unknown[]) => Promise<unknown>>([[ROOT, rootUpload]])
+  runtime.ctx.provide('fileUpload', {
+    available: true,
+    upload: (owner: Context, ...args: unknown[]) => {
+      const id = runtime.sessions.scopeOf(owner)
+      const upload = id === undefined ? undefined : uploads.get(id)
+      if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture')
+      return upload(...args)
+    },
+  } as never)
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const connectWorkspace = vi.fn(async () => ROOT)
   runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never)
@@ -94,7 +105,7 @@ async function bench() {
     conversationApi(id).injected.hooks.conversationViews
   return {
     runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
-    inputApi, viewSource, sessionFake, connectWorkspace,
+    inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads,
   }
 }
 
@@ -275,7 +286,7 @@ describe('Conversation inject API', () => {
     expect(b.composerApi(ROOT).addFiles?.([
       new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }),
     ])).toBeNull()
-    await vi.waitFor(() => { expect(b.sessionFake.uploadFile).toHaveBeenCalledOnce() })
+    await vi.waitFor(() => { expect(b.rootUpload).toHaveBeenCalledOnce() })
 
     b.connectWorkspace.mockResolvedValueOnce(ROOT)
     await resident.selectWorkspace('workspace-1' as WorkspaceId)
@@ -283,14 +294,15 @@ describe('Conversation inject API', () => {
     expect(state.getSnapshot().draft).toBe('carry me')
 
     const other = 'other-1' as SessionId
-    const targetUpload = vi.fn<ISession['uploadFile']>(() => Promise.resolve({
+    const targetUpload = vi.fn(() => Promise.resolve({
       ok: true,
       value: {
         receiptId: 'target-receipt' as never,
         file: { attachmentId: 'target-file' as never, name: 'draft.pdf', bytes: 1 },
       },
     }))
-    await b.runtime.sessions.add({ id: other, session: { uploadFile: targetUpload } }, { current: false })
+    b.uploads.set(other, targetUpload)
+    await b.runtime.sessions.add({ id: other, session: {} }, { current: false })
     b.connectWorkspace.mockResolvedValueOnce(other)
     await resident.selectWorkspace('workspace-2' as WorkspaceId)
     expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })

+ 0 - 1
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -49,7 +49,6 @@ function fakeSession(): SessionFace {
     subscribe: listener => snapshot.subscribe(listener),
     beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }),
     prompt: () => Promise.reject(new Error('unused fake Session operation')),
-    uploadFile: () => Promise.reject(new Error('unused fake Session operation')),
     readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
     updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
     cancel: () => Promise.reject(new Error('unused fake Session operation')),

+ 38 - 19
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -3,7 +3,7 @@
 // TestSessions mints tagged scopes through the production createScope, so the
 // service's scopeOf/binding path runs against production resolution (no local
 // tag probe).
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import type {
@@ -14,8 +14,26 @@ import { InputHub } from '../src/client/input/hub.ts'
 import { ConversationController } from '../src/client/service.ts'
 import { zh } from '../src/client/locales.ts'
 
+/** Test upload service that preserves each fixture Session's local upload override. */
+class TestFileUpload extends Service {
+  constructor(ctx: Context) {
+    super(ctx, 'fileUpload')
+  }
+
+  upload(owner: Context, ...args: unknown[]): Promise<unknown> {
+    const sessions = this.ctx.get('sessions') as {
+      sessionOf(ctx: Context): object | undefined
+    }
+    const session = sessions.sessionOf(owner) as { uploadFile?: (...input: unknown[]) => Promise<unknown> } | undefined
+    if (session?.uploadFile === undefined) throw new Error('test file upload has no Session override')
+    return session.uploadFile(...args)
+  }
+}
+
 async function bench(maxConcurrentFileUploads = 2) {
   const runtime = await SlotTestRuntime.create()
+  const uploadFiber = runtime.ctx.plugin(TestFileUpload)
+  await uploadFiber.await()
   const prompt = vi.fn((
     _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string,
   ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
@@ -92,7 +110,7 @@ describe('ConversationController', () => {
     const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
     const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
     try {
-      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
       ])
       if (attachment === undefined) throw new Error('draft attachment missing')
@@ -112,7 +130,7 @@ describe('ConversationController', () => {
     const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached')
     const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
     try {
-      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }),
       ])
       if (attachment === undefined) throw new Error('draft attachment missing')
@@ -141,7 +159,7 @@ describe('ConversationController', () => {
       },
     }))
     ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
-    const drafts = b.root.createDrafts(session, [
+    const drafts = b.root.createDrafts(session.sessionId, [
       new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
       new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }),
     ])
@@ -169,7 +187,7 @@ describe('ConversationController', () => {
           file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 },
         },
       }))
-    const drafts = b.root.createDrafts(session, [
+    const drafts = b.root.createDrafts(session.sessionId, [
       new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }),
       new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }),
     ])
@@ -212,7 +230,7 @@ describe('ConversationController', () => {
       return gate.promise.finally(() => { active -= 1 })
     })
     ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
-    const drafts = b.root.createDrafts(session, ['one', 'two', 'three', 'four', 'removed'].map(name =>
+    const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name =>
       new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })))
 
     expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt'])
@@ -261,7 +279,7 @@ describe('ConversationController', () => {
       return settled.promise
     })
     ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
-    const [attachment] = b.root.createDrafts(session, [
+    const [attachment] = b.root.createDrafts(session.sessionId, [
       new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }),
     ])
     if (attachment === undefined) throw new Error('file draft missing')
@@ -309,7 +327,7 @@ describe('ConversationController', () => {
       })
     })
     ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload
-    const [attachment] = b.root.createDrafts(source, [
+    const [attachment] = b.root.createDrafts(source.sessionId, [
       new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }),
     ])
     if (attachment === undefined) throw new Error('file draft missing')
@@ -324,7 +342,8 @@ describe('ConversationController', () => {
         },
       })),
     }
-    b.root.rebindDraftFiles(target as never, [attachment.id])
+    await b.runtime.sessions.add({ id: 's2', session: target })
+    b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id])
 
     expect(sourceSignal?.aborted).toBe(true)
     await vi.waitFor(() => {
@@ -349,7 +368,7 @@ describe('ConversationController', () => {
       })
     })
     ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
-    const [attachment] = b.root.createDrafts(session, [
+    const [attachment] = b.root.createDrafts(session.sessionId, [
       new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }),
     ])
     if (attachment === undefined) throw new Error('file draft missing')
@@ -378,7 +397,7 @@ describe('ConversationController', () => {
         file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 },
       },
     }))
-    const [attachment] = b.root.createDrafts(session, [
+    const [attachment] = b.root.createDrafts(session.sessionId, [
       new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }),
     ])
     if (attachment === undefined) throw new Error('file draft missing')
@@ -417,7 +436,7 @@ describe('ConversationController', () => {
         })
       },
     )
-    b.root.createDrafts(session, [
+    b.root.createDrafts(session.sessionId, [
       new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }),
     ])
     await vi.waitFor(() => { expect(uploadSignal).toBeDefined() })
@@ -476,7 +495,7 @@ describe('sendSession submission echo', () => {
   it('registers the echo before serialization and prompts with its identity', async () => {
     const b = await echoBench()
     try {
-      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }),
       ])
       const session = b.runtime.sessions.binding('s1')!.session
@@ -522,7 +541,7 @@ describe('sendSession submission echo', () => {
           file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 },
         },
       }))
-      const drafts = b.root.createDrafts(session, [
+      const drafts = b.root.createDrafts(session.sessionId, [
         new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
         new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }),
         new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }),
@@ -604,7 +623,7 @@ describe('sendSession submission echo', () => {
       const seedImageUrl = vi.fn(() => true)
       b.runtime.ctx.provide('uiConversation')
       b.runtime.ctx.set('uiConversation', { seedImageUrl })
-      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }),
       ])
       const session = b.runtime.sessions.binding('s1')!.session
@@ -644,7 +663,7 @@ describe('sendSession submission echo', () => {
       b.prompt.mockResolvedValueOnce({
         ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }),
       } as never)
-      const attachments = b.root.createDrafts(session, [
+      const attachments = b.root.createDrafts(session.sessionId, [
         new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }),
         new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }),
       ])
@@ -674,7 +693,7 @@ describe('sendSession submission echo', () => {
     }
     vi.stubGlobal('FileReader', FailingReader)
     try {
-      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }),
       ])
       const session = b.runtime.sessions.binding('s1')!.session
@@ -754,12 +773,12 @@ describe('draft image dimension probe', () => {
     }
     vi.stubGlobal('Image', InstantImage)
     try {
-      const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }),
       ])
       expect(probed).toMatchObject({ width: 640, height: 480 })
       vi.stubGlobal('Image', undefined)
-      const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
+      const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [
         new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }),
       ])
       expect(unprobed?.kind).toBe('image')

+ 1 - 1
packages/experimental/webworker-runtime/tsconfig.json

@@ -30,7 +30,7 @@
       "path": "../../client/connection/tsconfig.host.json"
     },
     {
-      "path": "../../client/file-upload"
+      "path": "../../client/file-upload/tsconfig.client.json"
     },
     {
       "path": "../../host/webserver"

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

@@ -881,6 +881,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'fileUploads',
+    summary: 'Host service owning upload storage and Agent-scoped staged receipts.',
+    description: 'Host service owning upload storage and Agent-scoped staged receipts.',
+    methods: [
+      {
+        signature: 'registerAgentResolver(resolve: AgentResolver): () => void',
+        description: 'Register the ordinary-Session resolver used when a raw upload addresses a cold Session.',
+        parameters: [{ name: 'resolve', description: 'resolver that returns the exact live Agent or throws a Remote error.' }],
+        returns: 'disposer removing this resolver.',
+      },
+      {
+        signature: '@Remote(\'upload\') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise<FileUploadValue>',
+        description: 'Persist one encoded upload and stage it under the Agent receiver selected by Typert.',
+        parameters: [{ name: 'agent', description: 'receiving Agent resolved from the Remote Agent scope.' }, { name: 'request', description: 'canonical base64 bytes and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }],
+        returns: 'the staged receipt and durable file reference.',
+      },
+      {
+        signature: 'async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable<Uint8Array> readonly signal?: AbortSignal readonly name?: string }): Promise<FileUploadValue>',
+        description: 'Persist raw chunks for one Session without aggregating the upload.',
+        parameters: [{ name: 'request', description: 'Session identity, ordered bytes, cancellation, and optional display name.' }],
+        returns: 'the staged receipt and durable file reference.',
+      },
+      {
+        signature: 'resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined',
+        description: 'Resolve one staged receipt inside its receiving Agent scope.',
+        parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptId', description: 'opaque receipt minted for one completed upload.' }],
+        returns: 'durable file reference, or `undefined` for an unknown or foreign receipt.',
+      },
+      {
+        signature: 'bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void',
+        description: 'Bind receipts to an accepted prompt and return a rollback for delivery failure.',
+        parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptIds', description: 'distinct staged receipts referenced by the prompt.' }, { name: 'requestId', description: 'prompt identity later observed in queue or history.' }],
+        returns: 'rollback restoring every prior binding.',
+      },
+      {
+        signature: 'retirePrompt(agent: Agent, requestId: string): void',
+        description: 'Retire every receipt accepted by one removed queue occurrence.',
+        parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'requestId', description: 'prompt identity carried by the queue occurrence.' }],
+      },
+    ],
+  },
   {
     key: 'fs',
     summary: 'Abstract filesystem provider.',
@@ -1450,12 +1492,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'request', description: 'Session and attachment identities used for authorization.' }],
         returns: 'the durable attachment reference and base64-encoded bytes.',
       },
-      {
-        signature: '@Remote(\'uploadFile\') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue>',
-        description: 'Persist one encoded file upload verbatim and stage it for a later prompt on the same Session.',
-        parameters: [{ name: 'request', description: 'Session identity, base64 payload, and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }],
-        returns: 'an opaque per-upload receipt and the durable file reference.',
-      },
       {
         signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue',
         description: 'Mutate one still-pending queue occurrence on a live Agent.',
@@ -3451,6 +3487,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AgentPresetRow',
     declaration: 'export interface AgentPresetRow {\n    readonly id: string;\n    readonly trust: PresetTrust;\n    readonly isDefault: boolean;\n    readonly name?: string;\n    readonly description?: string;\n    readonly broken?: string;\n}',
   },
+  {
+    name: 'AgentResolver',
+    declaration: 'export type AgentResolver = (sessionId: SessionId) => Promise<Agent>;',
+  },
   {
     name: 'AgentSetup',
     declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void;',
@@ -3995,6 +4035,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'EditGoalRequest',
     declaration: 'export interface EditGoalRequest {\n    readonly objective?: string;\n    readonly maxGoalRounds?: number;\n}',
   },
+  {
+    name: 'EncodedFileUploadRequest',
+    declaration: 'export interface EncodedFileUploadRequest {\n    readonly data: string;\n    readonly name?: string;\n}',
+  },
   {
     name: 'EncodedImageAttachment',
     declaration: 'export interface EncodedImageAttachment {\n    mediaType: ImageMediaType;\n    data: string;\n    name?: string;\n}',
@@ -4031,6 +4075,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'FileUploadReceiptId',
     declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;',
   },
+  {
+    name: 'FileUploadValue',
+    declaration: 'export interface FileUploadValue {\n    readonly receiptId: FileUploadReceiptId;\n    readonly file: FileAttachmentRef;\n}',
+  },
   {
     name: 'FinishReason',
     declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];',
@@ -5267,14 +5315,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionUpdateQueueValue',
     declaration: 'export interface SessionUpdateQueueValue {\n    readonly accepted: true;\n}',
   },
-  {
-    name: 'SessionUploadFileRequest',
-    declaration: 'export interface SessionUploadFileRequest {\n    readonly sessionId: SessionId;\n    readonly data: string;\n    readonly name?: string;\n}',
-  },
-  {
-    name: 'SessionUploadFileValue',
-    declaration: 'export interface SessionUploadFileValue {\n    readonly receiptId: FileUploadReceiptId;\n    readonly file: FileAttachmentRef;\n}',
-  },
   {
     name: 'SessionWireEvent',
     declaration: 'export interface SessionWireEvent {\n    readonly type: string;\n    readonly seq: number;\n    readonly time: number;\n    readonly data: JsonValue;\n    readonly ignorable?: true;\n    readonly sourceEventSeqs?: number[];\n    readonly surfaceOp?: SessionWireSurfaceOp;\n}',

+ 0 - 8
packages/test-support/client-runtime/src/sessions.ts

@@ -120,14 +120,6 @@ export class FixtureSession implements SessionFace {
     throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`)
   }
 
-  /**
-   * Fail-loud stub; supply `uploadFile` on the fixture's session face to exercise it.
-   * @returns never — always throws.
-   */
-  uploadFile(): never {
-    throw new Error(`test session "${this.sessionId}": uploadFile is not stubbed — supply it on the fixture's session face`)
-  }
-
   /**
    * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
    * @returns never — always throws.

+ 0 - 1
packages/test-support/client-runtime/tests/runtime.client.spec.tsx

@@ -428,7 +428,6 @@ describe('fixture session face', () => {
     const bare = runtime.sessions.behavior('s1')
     expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
     expect(() => bare.readAttachment('att-1' as Parameters<typeof bare.readAttachment>[0])).toThrow(/readAttachment is not stubbed/)
-    expect(() => bare.uploadFile()).toThrow(/uploadFile is not stubbed/)
     expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
     expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
     expect(() => bare.command()).toThrow(/command is not stubbed/)

+ 33 - 0
pnpm-lock.yaml

@@ -705,6 +705,9 @@ importers:
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../client/connection
+      '@deepseek-ai/dsh-client-file-upload':
+        specifier: workspace:^
+        version: link:../../client/file-upload
       '@deepseek-ai/dsh-commands':
         specifier: workspace:^
         version: link:../../interaction/commands
@@ -1740,6 +1743,33 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-attachment':
+        specifier: workspace:^
+        version: link:../../attachment/attachment
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
+      '@deepseek-ai/dsh-commands':
+        specifier: workspace:^
+        version: link:../../interaction/commands
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+      '@deepseek-ai/dsh-util-crypto':
+        specifier: workspace:^
+        version: link:../../util/crypto
 
   packages/client/hmr:
     dependencies:
@@ -2212,6 +2242,9 @@ importers:
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
+      '@deepseek-ai/dsh-client-file-upload':
+        specifier: workspace:^
+        version: link:../file-upload
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale

+ 5 - 2
scripts/gen-cordis-catalog.ts

@@ -75,6 +75,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   deepseekLlmApiExtensions: 'llm-streaming.md',
   dynamicCordisRunner: 'extensions.md',
   e2b: 'subprocess.md',
+  fileUploads: 'attachment.md',
   fileReferences: 'session-reference.md',
   fs: 'filesystem.md',
   goals: 'goal.md',
@@ -342,9 +343,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SessionSelectModelValue: 'session.md',
   SessionSummary: 'session.md',
   SessionUpdateQueueRequest: 'session.md',
-  SessionUploadFileRequest: 'session.md',
-  SessionUploadFileValue: 'session.md',
   SessionUpdateQueueValue: 'session.md',
+  EncodedFileUploadRequest: 'attachment.md',
+  AgentResolver: 'attachment.md',
+  FileUploadReceiptId: 'attachment.md',
+  FileUploadValue: 'attachment.md',
   SessionStartSource: 'core.md',
   SessionLogSnapshot: 'session-query.md',
   SessionSurfaceSnapshot: 'session-query.md',

+ 1 - 1
tsconfig.client.json

@@ -54,7 +54,7 @@
     { "path": "./packages/experimental/inspector/tsconfig.client.json" },
     { "path": "./packages/client/hmr" },
     { "path": "./packages/client/connection/tsconfig.client.json" },
-    { "path": "./packages/client/file-upload" },
+    { "path": "./packages/client/file-upload/tsconfig.client.json" },
     { "path": "./packages/typert/registry" },
     { "path": "./packages/api/gateway/tsconfig.client.json" },
     { "path": "./packages/api/session-controller/tsconfig.client.json" },

+ 1 - 0
tsconfig.host.json

@@ -143,6 +143,7 @@
     { "path": "./packages/util/atomic-write" },
     { "path": "./packages/attachment/attachment" },
     { "path": "./packages/attachment/attachment-local" },
+    { "path": "./packages/client/file-upload/tsconfig.host.json" },
     { "path": "./packages/llm/llm" },
     { "path": "./packages/llm/deepseek-llm-api-extensions" },
     { "path": "./packages/llm/token-meter" },

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.