Bläddra i källkod

refactor(api): converge the Remote failure vocabulary and client surface

Single RemoteError with a merge-extensible, domain-prefixed code map;
owners throw at the failure point; streams surface marked failures;
clients consume ctx.remote directly with isRemoteFailure as the only
discrimination point and construct no failure instances.
imccyu 1 vecka sedan
förälder
incheckning
804b1ffbfc
100 ändrade filer med 1596 tillägg och 2191 borttagningar
  1. 1 1
      apps/web/tests/default-model.e2e.ts
  2. 3 0
      packages/api/gateway/package.json
  3. 49 5
      packages/api/gateway/src/client/index.ts
  4. 22 16
      packages/api/gateway/src/client/journal-stream.ts
  5. 8 2
      packages/api/gateway/src/client/snapshot-stream.ts
  6. 2 20
      packages/api/gateway/src/client/stream-client.ts
  7. 61 69
      packages/api/gateway/src/index.ts
  8. 35 0
      packages/api/gateway/src/remote-error-codes.ts
  9. 17 17
      packages/api/gateway/src/types.ts
  10. 15 12
      packages/api/gateway/tests/gateway-stream.host.spec.ts
  11. 20 20
      packages/api/gateway/tests/gateway.client.spec.ts
  12. 49 45
      packages/api/gateway/tests/gateway.host.spec.ts
  13. 1 0
      packages/api/gateway/tsconfig.client.json
  14. 1 0
      packages/api/gateway/tsconfig.host.json
  15. 10 17
      packages/api/remotes/src/client/index.ts
  16. 2 0
      packages/api/session-controller/package.json
  17. 17 26
      packages/api/session-controller/src/agent.ts
  18. 0 29
      packages/api/session-controller/src/client/contract/result.ts
  19. 5 6
      packages/api/session-controller/src/client/contract/session.ts
  20. 2 2
      packages/api/session-controller/src/client/contract/sessions.ts
  21. 3 3
      packages/api/session-controller/src/client/contract/snapshot.ts
  22. 2 7
      packages/api/session-controller/src/client/index.ts
  23. 60 81
      packages/api/session-controller/src/client/sessions/manager.ts
  24. 4 4
      packages/api/session-controller/src/client/sessions/service.ts
  25. 57 132
      packages/api/session-controller/src/client/sessions/session.ts
  26. 4 23
      packages/api/session-controller/src/client/transport.ts
  27. 56 87
      packages/api/session-controller/src/commands.ts
  28. 16 20
      packages/api/session-controller/src/history.ts
  29. 13 17
      packages/api/session-controller/src/index.ts
  30. 9 13
      packages/api/session-controller/src/list.ts
  31. 11 22
      packages/api/session-controller/src/skill-catalog.ts
  32. 30 46
      packages/api/session-controller/src/types.ts
  33. 6 7
      packages/api/session-controller/tests/agent.host.spec.ts
  34. 3 1
      packages/api/session-controller/tests/client-apply.client.spec.ts
  35. 0 11
      packages/api/session-controller/tests/client-contract.client.spec.ts
  36. 22 17
      packages/api/session-controller/tests/commands-create-fork.host.spec.ts
  37. 11 11
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  38. 2 1
      packages/api/session-controller/tests/controller.host.spec.ts
  39. 45 68
      packages/api/session-controller/tests/fake-api.client.ts
  40. 39 50
      packages/api/session-controller/tests/manager.client.spec.ts
  41. 15 20
      packages/api/session-controller/tests/session-cold.host.spec.ts
  42. 2 2
      packages/api/session-controller/tests/session-fork.host.spec.ts
  43. 18 24
      packages/api/session-controller/tests/session-models.host.spec.ts
  44. 7 7
      packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
  45. 3 2
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  46. 12 5
      packages/api/session-controller/tests/session-presets.host.spec.ts
  47. 3 3
      packages/api/session-controller/tests/session-rename.host.spec.ts
  48. 18 18
      packages/api/session-controller/tests/session-search.host.spec.ts
  49. 7 7
      packages/api/session-controller/tests/session-skills.host.spec.ts
  50. 72 49
      packages/api/session-controller/tests/session.client.spec.ts
  51. 18 31
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  52. 9 9
      packages/api/session-controller/tests/test-remote.ts
  53. 11 8
      packages/api/session-controller/tests/transport.client.spec.ts
  54. 16 16
      packages/api/session-controller/tests/transport.host.spec.ts
  55. 1 0
      packages/api/session-controller/tsconfig.host.json
  56. 19 21
      packages/api/settings-controller/src/credentials.ts
  57. 49 101
      packages/api/settings-controller/src/index.ts
  58. 19 39
      packages/api/settings-controller/src/types.ts
  59. 10 13
      packages/api/settings-controller/tests/credentials-controller.host.spec.ts
  60. 33 48
      packages/api/settings-controller/tests/settings-controller.host.spec.ts
  61. 5 10
      packages/api/workspace-controller/src/client/index.ts
  62. 8 32
      packages/api/workspace-controller/src/client/model.ts
  63. 1 1
      packages/api/workspace-controller/src/client/service.ts
  64. 15 25
      packages/api/workspace-controller/src/commands.ts
  65. 29 26
      packages/api/workspace-controller/src/directory-picker.ts
  66. 20 41
      packages/api/workspace-controller/src/types.ts
  67. 15 14
      packages/api/workspace-controller/tests/directory-picker.host.spec.ts
  68. 25 45
      packages/api/workspace-controller/tests/model.client.spec.ts
  69. 19 32
      packages/api/workspace-controller/tests/transport.client.spec.ts
  70. 21 22
      packages/api/workspace-controller/tests/workspace-controller.host.spec.ts
  71. 0 2
      packages/client/connection/src/client/api.ts
  72. 36 36
      packages/client/connection/src/client/fixture.ts
  73. 1 1
      packages/client/connection/src/client/index.ts
  74. 2 2
      packages/client/connection/src/rpc-host.ts
  75. 1 28
      packages/client/connection/src/rpc.ts
  76. 1 1
      packages/client/connection/tests/api-helpers.client.spec.ts
  77. 3 3
      packages/client/connection/tests/fixture-commands.client.spec.ts
  78. 23 23
      packages/client/connection/tests/fixture.client.spec.ts
  79. 2 2
      packages/client/connection/tests/node-half.host.spec.ts
  80. 2 2
      packages/client/connection/tests/rpc-schema.host.spec.ts
  81. 1 1
      packages/client/locale/src/client/index.ts
  82. 1 2
      packages/client/locale/tests/apply.client.spec.ts
  83. 0 1
      packages/client/locale/tests/document-language.client.spec.ts
  84. 1 2
      packages/client/locale/tests/invariant.client.spec.ts
  85. 3 4
      packages/client/ui-agent-preset/src/client/index.ts
  86. 21 28
      packages/client/ui-agent-preset/src/client/seat-store.ts
  87. 46 60
      packages/client/ui-agent-preset/src/client/section-store.ts
  88. 28 55
      packages/client/ui-agent-preset/src/client/settings-store.ts
  89. 22 29
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  90. 86 149
      packages/client/ui-agent-preset/tests/section-store.client.spec.ts
  91. 79 150
      packages/client/ui-agent-preset/tests/settings-store.client.spec.ts
  92. 2 2
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  93. 1 2
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  94. 1 2
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  95. 2 3
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  96. 1 2
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  97. 5 5
      packages/client/ui-commands/src/client/service.ts
  98. 5 9
      packages/client/ui-commands/tests/service.client.spec.ts
  99. 3 2
      packages/client/ui-conversation/src/client/image-labels.ts
  100. 4 4
      packages/client/ui-conversation/src/client/input/hub.ts

+ 1 - 1
apps/web/tests/default-model.e2e.ts

@@ -148,7 +148,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
       sessionId: SessionId(await createSession('default-model-refusal')),
       mode: 'queue',
       content: [{ type: 'text', text: 'hi' }],
-    }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } })
+    }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/model-unavailable' })
 
     // The way out stays open. Locking the model seat with everything else
     // would leave the composer asking for the one thing it prevents.

+ 3 - 0
packages/api/gateway/package.json

@@ -35,6 +35,9 @@
   },
   "dsh": {
     "client": {
+      "external": [
+        "@deepseek-ai/dsh-typert-protocol"
+      ],
       "inject": [
         "@deepseek-ai/dsh-typert-registry",
         "@deepseek-ai/dsh-client-connection"

+ 49 - 5
packages/api/gateway/src/client/index.ts

@@ -5,6 +5,8 @@
  */
 
 import { Service } from '@deepseek-ai/cordis'
+import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts'
 import type { Context } from '@deepseek-ai/cordis'
 import type {
   ConnectionHandle,
@@ -13,6 +15,7 @@ import type {
   InvocationDescriptor,
   TypertClientEventListener,
   TypertClientRemote,
+  RemoteFailure,
   RemoteResult,
   TypertCodec,
   TypertDisposer,
@@ -21,7 +24,6 @@ import type {
 } from '@deepseek-ai/dsh-typert-protocol'
 import {
   RemoteStreamCarrierError,
-  RemoteStreamError,
   RemoteStreamMuxClient,
 } from './stream-client.ts'
 import { ClientRemoteEvents } from './remote-events.ts'
@@ -30,7 +32,7 @@ import {
   type RemoteStreamOptions,
 } from './remote-stream.ts'
 
-export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts'
+export { RemoteStreamCarrierError } from './stream-client.ts'
 export { RemoteJournalStream } from './journal-stream.ts'
 export type {
   RemoteJournalChange,
@@ -104,6 +106,20 @@ export interface ClientRemote extends TypertClientRemote {
    * @returns a single-consumer stream annotated with physical generation ids.
    */
   $stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item>
+  /**
+   * Fixed Host facts as plain reads: no store, no subscription, no generation
+   * counter. `home` stays undefined until the first ready frame and reflects
+   * the latest one afterwards.
+   */
+  readonly $host: RemoteHostFacts
+}
+
+/** The fixed Host facts exposed on `ctx.remote.$host`. */
+export interface RemoteHostFacts {
+  /** Host home directory from the ready frame, undefined before it. */
+  readonly home: string | undefined
+  /** Whether the carrier connects to the local Host. */
+  readonly isLoopback: boolean
 }
 
 declare module '@deepseek-ai/cordis' {
@@ -166,6 +182,14 @@ class ClientRemoteService extends Service implements ClientRemote {
     return new RemoteStream(this.connection, options)
   }
 
+  get $host(): RemoteHostFacts {
+    const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined ?? this.connection
+    return {
+      home: connection.generation.getSnapshot()?.host.home,
+      isLoopback: connection.isLoopback,
+    }
+  }
+
   async $mount(contribution: TypertRemoteContribution): ReturnType<TypertClientRemote['$mount']> {
     const callerCtx = this.ctx
     const owned = callerCtx.effect(async () => {
@@ -410,7 +434,7 @@ class ClientRemoteService extends Service implements ClientRemote {
     try {
       const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal)
       if (!mountActive(token)) return withdrawn(endpoint)
-      if (!result.ok) return { ok: false, error: result.error }
+      if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) }
       return { ok: true, value: result.value }
     } catch (error) {
       // Carrier throws (offline or abort) are outcomes of the call, not assembly
@@ -698,7 +722,27 @@ function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<
 }
 
 function internalFailure(message: string): Extract<RemoteResult<never>, { readonly ok: false }> {
-  return { ok: false, error: { code: 'internal', message, details: {} } }
+  return { ok: false, error: new RemoteError('gateway/internal', message, {}) }
+}
+
+/**
+ * Whether a caught value is a Remote failure this face delivered or threw.
+ * The one consumer-facing discrimination point: marked instances carry their
+ * Host code; anything else is a local fault the caller should let crash.
+ * @param error - a caught value.
+ * @returns true when the value narrows to RemoteFailure.
+ */
+export function isRemoteFailure(error: unknown): error is RemoteFailure {
+  return remoteErrorOf(error) !== undefined
+}
+
+/**
+ * Rebuild the wire failure as a local RemoteError instance so the error branch
+ * carries a real Error and `throw result.error` keeps throw semantics. The wire
+ * is a validation boundary: codes outside the merged map surface as-is.
+ */
+function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure {
+  return new RemoteError(error.code as never, error.message, error.details as never)
 }
 
 type MarkedConnectionStreamFailure = Error & {
@@ -715,7 +759,7 @@ async function *normalizeConnectionStream(source: AsyncIterable<unknown>): Async
     if (!(error instanceof Error)) throw error
     const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure
     if (marker?.kind === 'remote') {
-      throw new RemoteStreamError(marker.code, error.message, marker.details)
+      throw new RemoteError(marker.code as never, error.message, marker.details as never)
     }
     if (marker?.kind === 'carrier') {
       throw new RemoteStreamCarrierError(error.message, { cause: error })

+ 22 - 16
packages/api/gateway/src/client/journal-stream.ts

@@ -1,5 +1,6 @@
 /** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */
 
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { RemoteStreamCarrierError } from './stream-client.ts'
 import type {
   RemoteStream,
@@ -7,6 +8,11 @@ import type {
   RemoteStreamOptions,
 } from './remote-stream.ts'
 
+/** Host-side stream protocol violation, marked so consumers surface it as an error state. */
+function protocolViolation(message: string): RemoteError<'gateway/internal'> {
+  return new RemoteError('gateway/internal', message, {})
+}
+
 /** Transport-neutral opening snapshot or journal entry. */
 export type RemoteJournalFrame<Entry, Cursor, Page> =
   | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page }
@@ -100,7 +106,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       open: signal => this.follow(this.initialRequest, signal),
       ended: accepted => accepted
         ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`)
-        : new Error(
+        : protocolViolation(
           `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`,
         ),
       ...(options.carrierFailed === undefined
@@ -153,7 +159,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     const iterator = this.stream[Symbol.asyncIterator]()
     try {
       const first = await this.takeNext(iterator)
-      if (first.done) throw new Error(`${this.options.name} ended before its opening cursor`)
+      if (first.done) throw protocolViolation(`${this.options.name} ended before its opening cursor`)
       this.replaceGeneration(first.value, false)
       this.opened = true
       this.done = this.consume(iterator)
@@ -182,7 +188,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     if (tail !== undefined && before !== undefined
       && !this.options.follows(this.options.last(tail), before)) {
       this.options.publish({ type: 'prepend', page, entries: [], hasMore: false })
-      throw new Error(`${this.options.name} history page is discontinuous`)
+      throw protocolViolation(`${this.options.name} history page is discontinuous`)
     }
     const first = accepted[0]
     if (first !== undefined) this.firstCursor = this.options.first(first)
@@ -228,7 +234,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
           continue
         }
         if (item.value.type === 'opened') {
-          throw new Error(`${this.options.name} emitted more than one opening cursor`)
+          throw protocolViolation(`${this.options.name} emitted more than one opening cursor`)
         }
         await this.acceptEntry(item.value.entry, item, iterator)
       }
@@ -250,12 +256,12 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     resumed: boolean,
   ): { readonly cursor: Cursor; readonly page: Page } {
     if (item.value.type !== 'opened') {
-      throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`)
+      throw protocolViolation(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`)
     }
     const cursor = item.value.cursor
     if (resumed && this.lastCursor !== undefined
       && this.options.compare(cursor, this.lastCursor) < 0) {
-      throw new Error(
+      throw protocolViolation(
         `${this.options.name} resumed at a cursor behind the last applied entry`,
       )
     }
@@ -290,7 +296,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     const last = this.lastCursor as Cursor
     if (this.options.compare(cursor, last) <= 0) return
     if (this.options.compare(first, last) <= 0) {
-      throw new Error(`${this.options.name} emitted a partially overlapping entry`)
+      throw protocolViolation(`${this.options.name} emitted a partially overlapping entry`)
     }
     if (!this.options.follows(last, first)) {
       const request = this.repairPageRequest()
@@ -350,7 +356,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       target = this.maxCursor(requiredCursor, queued)
     }
     if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) {
-      throw new Error(`${this.options.name} page did not reach its opening cursor`)
+      throw protocolViolation(`${this.options.name} page did not reach its opening cursor`)
     }
     const first = entries[0]
     /* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */
@@ -400,12 +406,12 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       if (result.type === 'next-error') throw result.error
       if (result.value.done) {
         signal.throwIfAborted()
-        throw new Error(`${this.options.name} ended while reading its replacement page`)
+        throw protocolViolation(`${this.options.name} ended while reading its replacement page`)
       }
       const item = result.value.value
       if (item.generation !== generation) return { type: 'superseded', item }
       if (item.value.type === 'opened') {
-        throw new Error(`${this.options.name} emitted more than one opening cursor`)
+        throw protocolViolation(`${this.options.name} emitted more than one opening cursor`)
       }
       queued.push(item.value.entry)
     }
@@ -426,12 +432,12 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       }
       if (next.done) {
         this.stream.signal.throwIfAborted()
-        throw new Error(`${this.options.name} ended while replacing an aborted page generation`)
+        throw protocolViolation(`${this.options.name} ended while replacing an aborted page generation`)
       }
       const item = next.value
       if (item.generation !== generation) return { type: 'superseded', item }
       if (item.value.type === 'opened') {
-        throw new Error(`${this.options.name} emitted more than one opening cursor`)
+        throw protocolViolation(`${this.options.name} emitted more than one opening cursor`)
       }
       pending = this.nextResult(iterator)
     }
@@ -450,7 +456,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       const last = this.options.last(entry)
       if (this.options.compare(last, tail) <= 0) continue
       if (this.options.compare(first, tail) <= 0) {
-        throw new Error(`${this.options.name} replacement contains a partially overlapping entry`)
+        throw protocolViolation(`${this.options.name} replacement contains a partially overlapping entry`)
       }
       if (!this.options.follows(tail, first)) return undefined
       entries.push(entry)
@@ -516,7 +522,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     for (const entry of iterator) {
       const range = this.entryRange(entry)
       if (!this.options.follows(previousRange.last, range.first)) {
-        throw new Error(`${this.options.name} page contains discontinuous entries`)
+        throw protocolViolation(`${this.options.name} page contains discontinuous entries`)
       }
       previousRange = range
     }
@@ -526,7 +532,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     const first = this.options.first(entry)
     const last = this.options.last(entry)
     if (this.options.compare(first, last) > 0) {
-      throw new Error(`${this.options.name} entry has an inverted cursor range`)
+      throw protocolViolation(`${this.options.name} entry has an inverted cursor range`)
     }
     return { first, last }
   }
@@ -534,7 +540,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   private assertPageThrough(page: Page, through: Cursor): void {
     const tail = this.tailCursor(this.options.entries(page))
     if (this.options.compare(tail, through) !== 0) {
-      throw new Error(`${this.options.name} page did not end at its requested cursor`)
+      throw protocolViolation(`${this.options.name} page did not end at its requested cursor`)
     }
   }
 }

+ 8 - 2
packages/api/gateway/src/client/snapshot-stream.ts

@@ -1,7 +1,13 @@
 /** Baseline-and-delta protocol layered over a reconnecting Remote stream. */
 
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { RemoteStream } from './remote-stream.ts'
 
+/** Host-side stream protocol violation, marked so consumers surface it as an error state. */
+function protocolViolation(message: string): RemoteError<'gateway/internal'> {
+  return new RemoteError('gateway/internal', message, {})
+}
+
 /** Domain operations for one snapshot stream. */
 export interface RemoteSnapshotStreamOptions<Snapshot, Delta> {
   /** Diagnostic stream name used in protocol failures. */
@@ -69,7 +75,7 @@ export class RemoteSnapshotStream<Snapshot, Delta> {
         }
         if (this.options.isSnapshot(item.value)) {
           if (snapshotSeen) {
-            throw new Error(`${this.options.name} emitted more than one opening snapshot`)
+            throw protocolViolation(`${this.options.name} emitted more than one opening snapshot`)
           }
           this.options.replace(item.value)
           snapshotSeen = true
@@ -77,7 +83,7 @@ export class RemoteSnapshotStream<Snapshot, Delta> {
           continue
         }
         if (!snapshotSeen) {
-          throw new Error(`${this.options.name} emitted an update before its opening snapshot`)
+          throw protocolViolation(`${this.options.name} emitted an update before its opening snapshot`)
         }
         this.options.update(item.value)
       }

+ 2 - 20
packages/api/gateway/src/client/stream-client.ts

@@ -1,3 +1,4 @@
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 /** Browser owner for the Gateway multiplexed Remote stream socket. */
 
 import {
@@ -14,25 +15,6 @@ const RECONNECT_FACTOR = 2
 const RECONNECT_MAX_MS = 10_000
 
 /** One Host-reported Remote stream failure. */
-export class RemoteStreamError extends Error {
-  /** Stable carrier or Gateway error category. */
-  readonly code: string
-  /** Host-provided structured failure context. */
-  readonly details: object
-
-  /**
-   * @param code - stable Gateway or business error category.
-   * @param message - Host-provided failure description.
-   * @param details - Host-provided structured failure context.
-   */
-  constructor(code: string, message: string, details: object) {
-    super(message)
-    this.name = 'RemoteStreamError'
-    this.code = code
-    this.details = details
-  }
-}
-
 /** Physical Remote stream socket failure that may be retried by a domain transport. */
 export class RemoteStreamCarrierError extends Error {
   /**
@@ -105,7 +87,7 @@ export class RemoteStreamMuxClient {
         }
         terminal = true
         if (frame.type === 'error') {
-          throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details)
+          throw new RemoteError(frame.error.code as never, frame.error.message, frame.error.details as never)
         }
         return
       }

+ 61 - 69
packages/api/gateway/src/index.ts

@@ -11,10 +11,11 @@ import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
 import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import z from '@deepseek-ai/schemastery'
+export type { TypertGatewayFaultDetails } from './remote-error-codes.ts'
 import {
+  RemoteError,
+  remoteErrorOf,
   remoteMethods,
-  TypertLookupFailure,
-  TypertRemoteFailure,
   type InvocationDescriptor,
   type InvocationParameterDescriptor,
   type TypertCodec,
@@ -123,10 +124,12 @@ interface ResolvedConfig extends Config {
   readonly websocketHeartbeatIntervalMs: number
 }
 
-/** Dispatch failure produced outside the invoked business method. */
-export class TypertGatewayError extends Error {
-  /** Machine-readable failure category. */
-  readonly code: TypertGatewayErrorCode
+/**
+ * Dispatch failure produced outside the invoked business method. Rides the
+ * shared Remote failure vocabulary, so its code crosses the wire instead of
+ * folding to `internal`.
+ */
+export class TypertGatewayError extends RemoteError<TypertGatewayErrorCode> {
   /** Canonical `<namespace>/<method>` endpoint. */
   readonly endpoint: string
   /** Affected wire field when the failure is field-specific. */
@@ -145,26 +148,18 @@ export class TypertGatewayError extends Error {
     message: string,
     options: GatewayErrorOptions = {},
   ) {
-    super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
+    super(
+      code,
+      `typert gateway: ${endpoint}: ${message}`,
+      { endpoint, ...options.field === undefined ? {} : { field: options.field } },
+      options.cause === undefined ? undefined : { cause: options.cause },
+    )
     this.name = 'TypertGatewayError'
-    this.code = code
     this.endpoint = endpoint
     this.field = options.field
   }
 }
 
-/** Business invocation lost its carrier cancellation race. */
-class RemoteInvocationCancelled extends Error {
-  /**
-   * @param endpoint - canonical Remote endpoint.
-   * @param cause - business rejection observed after carrier cancellation.
-   */
-  constructor(endpoint: string, cause: unknown) {
-    super(`Remote invocation "${endpoint}" was aborted`, { cause })
-    this.name = 'RemoteInvocationCancelled'
-  }
-}
-
 /**
  * Resolve strict generated definitions or conservative SRC markers against
  * current Cordis Services and Typert providers.
@@ -303,7 +298,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const prepared = await this.prepareInvocation(request)
     if (prepared.descriptor.mode === 'stream') {
       throw new TypertGatewayError(
-        'signature-invalid',
+        'gateway/signature-invalid',
         prepared.endpoint,
         'stream Remote methods must be opened through the stream carrier',
       )
@@ -312,7 +307,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     try {
       return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
     } catch (error) {
-      if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error)
+      if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error)
       throw error
     }
   }
@@ -326,7 +321,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const prepared = await this.prepareInvocation(request)
     if (prepared.descriptor.mode !== 'stream') {
       throw new TypertGatewayError(
-        'signature-invalid',
+        'gateway/signature-invalid',
         prepared.endpoint,
         'unary Remote methods cannot be opened through the stream carrier',
       )
@@ -335,12 +330,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
     try {
       source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
     } catch (error) {
-      if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error)
+      if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error)
       throw error
     }
     if (!isIterable(source)) {
       throw new TypertGatewayError(
-        'result-invalid',
+        'gateway/result-invalid',
         prepared.endpoint,
         'stream Remote method did not return Iterable or AsyncIterable',
         { field: 'result' },
@@ -400,7 +395,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
       || !isPlainObject(payload.args)
       || Reflect.ownKeys(payload.args).length !== 0) {
       throw new TypertGatewayError(
-        'arguments-invalid',
+        'gateway/arguments-invalid',
         REMOTE_EVENT_STREAM_ENDPOINT,
         'forwarded Remote event stream requires an empty args object',
       )
@@ -408,7 +403,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const registration = this.remoteEvents
     if (registration === undefined) {
       throw new TypertGatewayError(
-        'service-unavailable',
+        'gateway/service-unavailable',
         REMOTE_EVENT_STREAM_ENDPOINT,
         'forwarded Remote event source is unavailable',
       )
@@ -611,7 +606,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const receiver = receiverContext.get(descriptor.service) as unknown
     if (!isObject(receiver)) {
       throw new TypertGatewayError(
-        'service-unavailable',
+        'gateway/service-unavailable',
         endpoint,
         `active Service ${JSON.stringify(descriptor.service)} is unavailable`,
       )
@@ -624,7 +619,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const method = Reflect.get(receiver, implementation) as unknown
     if (typeof method !== 'function') {
       throw new TypertGatewayError(
-        'method-unavailable',
+        'gateway/method-unavailable',
         endpoint,
         `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
       )
@@ -637,7 +632,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     if (strict !== undefined) return strict
     if (this.ctx.typert.local.hasSeen(endpoint)) {
       throw new TypertGatewayError(
-        'definition-unavailable',
+        'gateway/definition-unavailable',
         endpoint,
         'its strict definition was withdrawn and SRC fallback is forbidden',
       )
@@ -661,11 +656,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
       candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
     }
     if (candidates.length === 0) {
-      throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
+      throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
     }
     if (candidates.length > 1) {
       throw new TypertGatewayError(
-        'ambiguous-endpoint',
+        'gateway/ambiguous-endpoint',
         endpoint,
         `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
       )
@@ -683,7 +678,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const signalIndex = names.indexOf('signal')
     if (signalIndex >= 0 && signalIndex !== names.length - 1) {
       throw new TypertGatewayError(
-        'signature-invalid',
+        'gateway/signature-invalid',
         endpoint,
         'SRC cancellation parameter signal must be the final parameter',
         { field: 'signal' },
@@ -700,7 +695,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
         .filter(definition => definition.parameter === name)
       if (matches.length > 1) {
         throw new TypertGatewayError(
-          'signature-invalid',
+          'gateway/signature-invalid',
           endpoint,
           `parameter ${JSON.stringify(name)} matches multiple lookup providers`,
           { field: name },
@@ -718,7 +713,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
         }
       if (wires.has(parameter.wire)) {
         throw new TypertGatewayError(
-          'signature-invalid',
+          'gateway/signature-invalid',
           endpoint,
           `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
           { field: parameter.wire },
@@ -733,14 +728,14 @@ export class TypertGatewayService extends Service implements TypertGateway {
       const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
       if (provider === undefined) {
         throw new TypertGatewayError(
-          'context-unavailable',
+          'gateway/context-unavailable',
           endpoint,
           `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
         )
       }
       if (wires.has(provider.wire)) {
         throw new TypertGatewayError(
-          'signature-invalid',
+          'gateway/signature-invalid',
           endpoint,
           `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
           { field: provider.wire },
@@ -778,7 +773,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const provider = this.ctx.typert.contexts.getHost(invocation.context)
     if (provider === undefined) {
       throw new TypertGatewayError(
-        'context-unavailable',
+        'gateway/context-unavailable',
         endpoint,
         `Context provider ${JSON.stringify(invocation.context)} is unavailable`,
       )
@@ -786,7 +781,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     if (provider.wire !== invocation.wire
       || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
       throw new TypertGatewayError(
-        'provider-mismatch',
+        'gateway/provider-mismatch',
         endpoint,
         `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
         { field: invocation.wire },
@@ -797,9 +792,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
     try {
       context = await provider.resolve(identity)
     } catch (cause) {
-      if (cause instanceof TypertLookupFailure) throw cause
+      if (remoteErrorOf(cause) !== undefined) throw cause
       throw new TypertGatewayError(
-        'context-failed',
+        'gateway/context-failed',
         endpoint,
         `Context provider ${JSON.stringify(invocation.context)} failed`,
         { cause, field: invocation.wire },
@@ -807,7 +802,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     }
     if (context === undefined) {
       throw new TypertGatewayError(
-        'context-not-found',
+        'gateway/context-not-found',
         endpoint,
         `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
         { field: invocation.wire },
@@ -832,7 +827,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
     if (key === undefined) {
       throw new TypertGatewayError(
-        'lookup-unavailable',
+        'gateway/lookup-unavailable',
         endpoint,
         `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
         { field: parameter.wire },
@@ -841,7 +836,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     const provider = this.ctx.typert.lookups.get(key)
     if (provider === undefined) {
       throw new TypertGatewayError(
-        'lookup-unavailable',
+        'gateway/lookup-unavailable',
         endpoint,
         `lookup provider ${JSON.stringify(key)} is unavailable`,
         { field: parameter.wire },
@@ -850,7 +845,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     if (provider.wire !== parameter.wire
       || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
       throw new TypertGatewayError(
-        'provider-mismatch',
+        'gateway/provider-mismatch',
         endpoint,
         `lookup provider ${JSON.stringify(key)} does not match its strict definition`,
         { field: parameter.wire },
@@ -860,9 +855,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
     try {
       resolved = await provider.resolve(value)
     } catch (cause) {
-      if (cause instanceof TypertLookupFailure) throw cause
+      if (remoteErrorOf(cause) !== undefined) throw cause
       throw new TypertGatewayError(
-        'lookup-failed',
+        'gateway/lookup-failed',
         endpoint,
         `lookup provider ${JSON.stringify(key)} failed`,
         { cause, field: parameter.wire },
@@ -870,7 +865,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
     }
     if (resolved === undefined) {
       throw new TypertGatewayError(
-        'lookup-not-found',
+        'gateway/lookup-not-found',
         endpoint,
         `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
         { field: parameter.wire },
@@ -978,11 +973,11 @@ async function *cancellableStream(
   let rejectAbort: ((error: unknown) => void) | undefined
   const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
   const onAbort = (): void => {
-    rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason))
+    rejectAbort?.(remoteCancelled(endpoint, signal.reason))
   }
   signal.addEventListener('abort', onAbort, { once: true })
   try {
-    if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason)
+    if (signal.aborted) throw remoteCancelled(endpoint, signal.reason)
     while (true) {
       const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
       if (next.done === true) return
@@ -994,23 +989,20 @@ async function *cancellableStream(
   }
 }
 
+/** Carrier-signal cancellation as the shared failure vocabulary expresses it. */
+function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> {
+  return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause })
+}
+
 function rpcFailure(error: unknown): ConnectionRpcResult {
-  if (error instanceof RemoteInvocationCancelled) {
-    return {
-      ok: false,
-      error: { code: 'cancelled', message: error.message, details: {} },
-    }
-  }
-  if (error instanceof TypertLookupFailure) {
-    return { ok: false, error: error.failure as ConnectionRpcError }
-  }
-  if (error instanceof TypertRemoteFailure) {
-    return { ok: false, error: error.failure }
+  const remote = remoteErrorOf(error)
+  if (remote !== undefined) {
+    return { ok: false, error: { code: remote.code, message: remote.message, details: remote.details } }
   }
   return {
     ok: false,
     error: {
-      code: 'internal',
+      code: 'gateway/internal',
       message: error instanceof Error ? error.message : String(error),
       details: {},
     },
@@ -1035,7 +1027,7 @@ function validateBinding(
   const value = Reflect.get(original, 'typertRemote') as unknown
   if (value === undefined) {
     throw new TypertGatewayError(
-      'binding-invalid',
+      'gateway/binding-invalid',
       endpoint,
       `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`,
     )
@@ -1059,7 +1051,7 @@ function readBinding(
     || typeof Reflect.get(value, 'namespace') !== 'string'
     || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
     throw new TypertGatewayError(
-      'binding-invalid',
+      'gateway/binding-invalid',
       endpoint,
       `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`,
     )
@@ -1087,7 +1079,7 @@ function methodParameterNames(service: object, method: string, endpoint: string)
   }
   if (implementation === undefined) {
     throw new TypertGatewayError(
-      'method-unavailable',
+      'gateway/method-unavailable',
       endpoint,
       `Remote marker has no prototype method ${JSON.stringify(method)}`,
     )
@@ -1110,7 +1102,7 @@ function methodParameterNames(service: object, method: string, endpoint: string)
 
 function invalidSignature(endpoint: string, method: string): never {
   throw new TypertGatewayError(
-    'signature-invalid',
+    'gateway/signature-invalid',
     endpoint,
     `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
   )
@@ -1122,7 +1114,7 @@ function assertExactArguments(
   endpoint: string,
 ): void {
   if (!isPlainObject(args)) {
-    throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
+    throw new TypertGatewayError('gateway/arguments-invalid', endpoint, 'args must be a plain object')
   }
   const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
   if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
@@ -1141,7 +1133,7 @@ function assertExactArguments(
   const clauses: string[] = []
   if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
   if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
-  throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
+  throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
 }
 
 function decode(
@@ -1160,7 +1152,7 @@ function decode(
     return value
   } catch (cause) {
     throw new TypertGatewayError(
-      'input-invalid',
+      'gateway/input-invalid',
       endpoint,
       `wire field ${JSON.stringify(field)} failed boundary validation`,
       { cause, field },

+ 35 - 0
packages/api/gateway/src/remote-error-codes.ts

@@ -0,0 +1,35 @@
+/**
+ * Gateway infrastructure failure codes merged into the shared Remote failure
+ * vocabulary. Face-neutral: the Host face and the Client face each import this
+ * module so both programs see the same map entries.
+ */
+
+/** Wire details every Gateway infrastructure failure carries. */
+export interface TypertGatewayFaultDetails {
+  /** Canonical `<namespace>/<method>` endpoint. */
+  readonly endpoint: string
+  /** Affected wire field when the failure is field-specific. */
+  readonly field?: string
+}
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    'gateway/ambiguous-endpoint': TypertGatewayFaultDetails
+    'gateway/arguments-invalid': TypertGatewayFaultDetails
+    'gateway/binding-invalid': TypertGatewayFaultDetails
+    'gateway/context-failed': TypertGatewayFaultDetails
+    'gateway/context-not-found': TypertGatewayFaultDetails
+    'gateway/context-unavailable': TypertGatewayFaultDetails
+    'gateway/definition-unavailable': TypertGatewayFaultDetails
+    'gateway/input-invalid': TypertGatewayFaultDetails
+    'gateway/invocation-unavailable': TypertGatewayFaultDetails
+    'gateway/lookup-failed': TypertGatewayFaultDetails
+    'gateway/lookup-not-found': TypertGatewayFaultDetails
+    'gateway/lookup-unavailable': TypertGatewayFaultDetails
+    'gateway/method-unavailable': TypertGatewayFaultDetails
+    'gateway/provider-mismatch': TypertGatewayFaultDetails
+    'gateway/result-invalid': TypertGatewayFaultDetails
+    'gateway/service-unavailable': TypertGatewayFaultDetails
+    'gateway/signature-invalid': TypertGatewayFaultDetails
+  }
+}

+ 17 - 17
packages/api/gateway/src/types.ts

@@ -99,23 +99,23 @@ export interface TypertGatewayWireStream {
 
 /** Stable infrastructure and boundary failures emitted before or after business execution. */
 export type TypertGatewayErrorCode =
-  | 'ambiguous-endpoint'
-  | 'arguments-invalid'
-  | 'binding-invalid'
-  | 'context-failed'
-  | 'context-not-found'
-  | 'context-unavailable'
-  | 'definition-unavailable'
-  | 'input-invalid'
-  | 'invocation-unavailable'
-  | 'lookup-failed'
-  | 'lookup-not-found'
-  | 'lookup-unavailable'
-  | 'method-unavailable'
-  | 'provider-mismatch'
-  | 'result-invalid'
-  | 'service-unavailable'
-  | 'signature-invalid'
+  | 'gateway/ambiguous-endpoint'
+  | 'gateway/arguments-invalid'
+  | 'gateway/binding-invalid'
+  | 'gateway/context-failed'
+  | 'gateway/context-not-found'
+  | 'gateway/context-unavailable'
+  | 'gateway/definition-unavailable'
+  | 'gateway/input-invalid'
+  | 'gateway/invocation-unavailable'
+  | 'gateway/lookup-failed'
+  | 'gateway/lookup-not-found'
+  | 'gateway/lookup-unavailable'
+  | 'gateway/method-unavailable'
+  | 'gateway/provider-mismatch'
+  | 'gateway/result-invalid'
+  | 'gateway/service-unavailable'
+  | 'gateway/signature-invalid'
 
 /** Host dispatcher consumed by Connection adapters. */
 export interface TypertGateway {

+ 15 - 12
packages/api/gateway/tests/gateway-stream.host.spec.ts

@@ -12,9 +12,16 @@ import {
   type InvocationDescriptor,
   type TypertContextMap,
   type TypertContextWire,
-  TypertRemoteFailure,
+  RemoteError,
 } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    'fixture/rejected': { readonly retryable: boolean }
+    'fixture/broken': { readonly count: bigint }
+  }
+}
 import { provideBrowserCredentials } from './browser-credentials.ts'
 import TypertGatewayService, {
   TypertGatewayError,
@@ -118,16 +125,12 @@ class FeedService extends Service {
 
   @Remote({ mode: 'stream' })
   reject(): Iterable<string> {
-    throw new TypertRemoteFailure({
-      code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false },
-    })
+    throw new RemoteError('fixture/rejected', 'fixture rejected the stream', { retryable: false })
   }
 
   @Remote({ mode: 'stream' })
   rejectWithNonJsonDetails(): Iterable<string> {
-    throw new TypertRemoteFailure({
-      code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n },
-    })
+    throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n })
   }
 
   unary(label: string): string {
@@ -262,7 +265,7 @@ describe('Typert Remote streams', () => {
     }))).resolves.toEqual([1n])
     await expect(ctx.typertGateway.stream({
       namespace: 'feed', method: 'missing', args: {},
-    })).rejects.toMatchObject({ code: 'result-invalid' })
+    })).rejects.toMatchObject({ code: 'gateway/result-invalid' })
 
     await expect(collect(await ctx.typertGateway.stream({
       namespace: 'feed', method: 'src', args: { label: 'c' },
@@ -286,10 +289,10 @@ describe('Typert Remote streams', () => {
     const { ctx } = await setup(false)
     await expect(ctx.typertGateway.invoke({
       namespace: 'feed', method: 'sync', args: { label: 'a' },
-    })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial<TypertGatewayError>)
+    })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial<TypertGatewayError>)
     await expect(ctx.typertGateway.stream({
       namespace: 'feed', method: 'unary', args: { label: 'a' },
-    })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial<TypertGatewayError>)
+    })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial<TypertGatewayError>)
   })
 
   it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => {
@@ -345,13 +348,13 @@ describe('Typert Remote streams', () => {
         { type: 'end', streamId: 'invalid' },
       ])
       expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({
-        type: 'error', error: { code: 'internal' },
+        type: 'error', error: { code: 'gateway/internal' },
       })
       expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({
         type: 'error',
         streamId: 'rejected',
         error: {
-          code: 'fixture-rejected',
+          code: 'fixture/rejected',
           message: 'fixture rejected the stream',
           details: { retryable: false },
         },

+ 20 - 20
packages/api/gateway/tests/gateway.client.spec.ts

@@ -1,3 +1,4 @@
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { Fiber } from '@deepseek-ai/cordis'
 import { describe, expect, expectTypeOf, it, vi } from 'vitest'
@@ -22,7 +23,6 @@ import type { ClientRemote } from '../src/client/index.ts'
 import { apply, inject, RemoteStream } from '../src/client/index.ts'
 import {
   RemoteStreamCarrierError,
-  RemoteStreamError,
   RemoteStreamMuxClient,
 } from '../src/client/stream-client.ts'
 
@@ -634,10 +634,10 @@ describe('Client Typert API', () => {
     expect(ctx.get('remote.probe')).toBeUndefined()
     expect(ctx.get('probe')).toBe(businessProbe)
     expect(ctx.typert.remotes.list()).toEqual([])
-    await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({
+    await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toMatchObject({
       ok: false,
       error: {
-        code: 'internal',
+        code: 'gateway/internal',
         message: 'client api: Remote method probe/create is no longer mounted',
         details: {},
       },
@@ -1079,10 +1079,10 @@ describe('Client Typert API', () => {
     await dispose()
     resolveCall({ ok: true, value: { ref: 'goal-1' } })
 
-    await expect(invocation).resolves.toEqual({
+    await expect(invocation).resolves.toMatchObject({
       ok: false,
       error: {
-        code: 'internal',
+        code: 'gateway/internal',
         message: 'client api: Remote method probe/create is no longer mounted',
         details: {},
       },
@@ -1235,14 +1235,14 @@ describe('Client Typert API', () => {
   })
 
   it('delivers an RPC failure in the error branch with the Host error verbatim', async () => {
-    const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
+    const rpcError = { code: 'gateway/internal' as const, message: 'host failed', details: {} }
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
     await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
 
     const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' })
     expect(outcome.ok).toBe(false)
     if (outcome.ok) throw new Error('expected the Client API invocation to report a failure')
-    expect(outcome.error).toBe(rpcError)
+    expect(outcome.error).toMatchObject(rpcError)
   })
 
   it('folds a transport throw into the error branch', async () => {
@@ -1250,10 +1250,10 @@ describe('Client Typert API', () => {
       .mockRejectedValue(new Error('carrier offline')))
     await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
 
-    await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
+    await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({
       ok: false,
       error: {
-        code: 'internal',
+        code: 'gateway/internal',
         message: 'client api: probe/create failed: carrier offline',
         details: {},
       },
@@ -1265,10 +1265,10 @@ describe('Client Typert API', () => {
       .mockRejectedValue('carrier exploded'))
     await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
 
-    await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
+    await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({
       ok: false,
       error: {
-        code: 'internal',
+        code: 'gateway/internal',
         message: 'client api: probe/create failed: carrier exploded',
         details: {},
       },
@@ -1472,7 +1472,7 @@ describe('Client Typert API', () => {
   it('fails the Connection generation when a result RPC is rejected', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({
       ok: false,
-      error: { code: 'internal', message: 'fixture result rejected', details: {} },
+      error: { code: 'gateway/internal', message: 'fixture result rejected', details: {} },
     })
     const { client, carrier, run } = await eventBench(call)
 
@@ -1554,7 +1554,7 @@ describe('Client Typert API', () => {
     })
     target.remote.$on('fixture/approval', () => Promise.reject(rejection))
 
-    carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled'))
+    carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'gateway/cancelled'))
 
     await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
     expect(call).toHaveBeenCalledWith(
@@ -1884,7 +1884,7 @@ describe('Client Typert API', () => {
     {
       name: 'Host failure',
       stop: (carrier: RemoteEventCarrier) => {
-        carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {}))
+        carrier.fail(new RemoteError('gateway/internal', 'fixture Host failed', {}))
       },
       message: 'fixture Host failed',
     },
@@ -2037,14 +2037,14 @@ describe('Client Typert API', () => {
       failure: Object.assign(new Error('fixture Host rejected the stream'), {
         dshRemoteStreamFailure: {
           kind: 'remote' as const,
-          code: 'fixture-rejected',
+          code: 'fixture/rejected',
           details: { retry: false },
         },
       }),
       assert: (error: unknown) => {
-        expect(error).toBeInstanceOf(RemoteStreamError)
+        expect(error).toBeInstanceOf(RemoteError)
         expect(error).toMatchObject({
-          code: 'fixture-rejected',
+          code: 'fixture/rejected',
           message: 'fixture Host rejected the stream',
           details: { retry: false },
         })
@@ -2122,14 +2122,14 @@ describe('Client Typert API', () => {
         type: 'error',
         streamId: failedOpen.streamId,
         error: {
-          code: 'lookup-unavailable',
+          code: 'gateway/lookup-unavailable',
           message: 'fixture stream failed',
           details: { lookup: 'missing' },
         },
       })
       await expect(failedItem).rejects.toMatchObject({
-        name: 'RemoteStreamError',
-        code: 'lookup-unavailable',
+        name: 'RemoteError',
+        code: 'gateway/lookup-unavailable',
         message: 'fixture stream failed',
         details: { lookup: 'missing' },
       })

+ 49 - 45
packages/api/gateway/tests/gateway.host.spec.ts

@@ -9,8 +9,8 @@ import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
 import {
   bindTypertRemote,
   Remote,
+  RemoteError,
   RemoteScope,
-  TypertLookupFailure,
   type InvocationDescriptor,
   type TypertContext,
   type TypertLookup,
@@ -37,6 +37,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
   interface TypertContextMap {
     gatewayFixture: TypertContext<string>
   }
+
+  interface RemoteErrorDetailsMap {
+    'session/agent-busy': { readonly reason: string }
+  }
 }
 
 const emptyModel: TypertContribution['model'] = {
@@ -452,7 +456,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'lookup-unavailable')
+    }), 'gateway/lookup-unavailable')
     expect(service.calls).toEqual([])
   })
 
@@ -485,7 +489,7 @@ describe('TypertGatewayService', () => {
     })).resolves.toBe('land')
     await expectCode(ctx.typertGateway.invoke({
       namespace: 'other', method: 'absent', args: {},
-    }), 'invocation-unavailable')
+    }), 'gateway/invocation-unavailable')
   })
 
   it('rejects SRC wire collisions and unavailable Context providers', async () => {
@@ -496,14 +500,14 @@ describe('TypertGatewayService', () => {
       namespace: 'colliding-wire',
       method: 'run',
       args: { agentId: 'agent-1' },
-    }), 'signature-invalid')
+    }), 'gateway/signature-invalid')
 
     const missing = await setup()
     await expectCode(missing.ctx.typertGateway.invoke({
       namespace: 'goals',
       method: 'rename',
       args: { agentId: 'agent-1', request: { title: 'land' } },
-    }), 'context-unavailable')
+    }), 'gateway/context-unavailable')
 
     const contextCollision = await setupGateway()
     await contextCollision.plugin(ContextWireService)
@@ -512,7 +516,7 @@ describe('TypertGatewayService', () => {
       namespace: 'context-wire',
       method: 'run',
       args: { agentId: 'agent-1' },
-    }), 'signature-invalid')
+    }), 'gateway/signature-invalid')
   })
 
   it('re-reads Service and providers on every strict invocation', async () => {
@@ -526,7 +530,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'lookup-unavailable')
+    }), 'gateway/lookup-unavailable')
 
     registerAgentLookup(ctx, agent)
     await serviceFiber.dispose()
@@ -534,7 +538,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'service-unavailable')
+    }), 'gateway/service-unavailable')
   })
 
   it('re-reads and contains Context providers', async () => {
@@ -548,7 +552,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'rename',
       args: { agentId: 'agent-1', request: { title: 'land' } },
-    }), 'context-unavailable')
+    }), 'gateway/context-unavailable')
 
     ctx.typert.contexts.registerHost('gatewayFixture', {
       ...contextProvider(scoped),
@@ -558,13 +562,13 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'rename',
       args: { agentId: 'agent-1', request: { title: 'land' } },
-    }), 'context-failed')
+    }), 'gateway/context-failed')
     expect(error.cause).toEqual(new Error('provider failed'))
   })
 
   it('preserves a Host Context policy rejection for the active RPC adapter', async () => {
     const { ctx } = await setup()
-    const rejection = new TypertLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } })
+    const rejection = new RemoteError('session/agent-busy', 'owned', { reason: 'subagent' })
     ctx.typert.contexts.registerHost('gatewayFixture', {
       ...contextProvider(ctx.extend()),
       resolve: async () => { throw rejection },
@@ -590,7 +594,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'rename',
       args: { agentId: 'agent-1', request: { title: 'land' } },
-    }), 'provider-mismatch')
+    }), 'gateway/provider-mismatch')
     await mismatch()
 
     ctx.typert.contexts.registerHost('gatewayFixture', {
@@ -601,7 +605,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'rename',
       args: { agentId: 'agent-1', request: { title: 'land' } },
-    }), 'context-not-found')
+    }), 'gateway/context-not-found')
   })
 
   it('contains lookup provider failures and missing identities', async () => {
@@ -615,7 +619,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'lookup-failed')
+    }), 'gateway/lookup-failed')
     expect(failure.cause).toEqual(new Error('lookup failed'))
     await throwing()
 
@@ -627,7 +631,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'lookup-not-found')
+    }), 'gateway/lookup-not-found')
     await missing()
 
     ctx.typert.lookups.register('gatewayFixture', {
@@ -650,7 +654,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'passthrough',
       args: { value: 'would pass through SRC' },
-    }), 'definition-unavailable')
+    }), 'gateway/definition-unavailable')
   })
 
   it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => {
@@ -665,7 +669,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'passthrough',
       args: { value: 'would pass through SRC' },
-    }), 'definition-unavailable')
+    }), 'gateway/definition-unavailable')
   })
 
   it('retains the no-downgrade guard across Gateway Service reloads', async () => {
@@ -684,7 +688,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'passthrough',
       args: { value: 'would pass through SRC' },
-    }), 'definition-unavailable')
+    }), 'gateway/definition-unavailable')
   })
 
   it('rejects ambiguous SRC endpoints independently of reflection order', async () => {
@@ -696,7 +700,7 @@ describe('TypertGatewayService', () => {
       namespace: 'shared',
       method: 'run',
       args: { value: 'ship' },
-    }), 'ambiguous-endpoint')
+    }), 'gateway/ambiguous-endpoint')
     expect(error.message).toContain('firstShared, secondShared')
   })
 
@@ -714,7 +718,7 @@ describe('TypertGatewayService', () => {
         namespace: testCase.namespace,
         method: 'run',
         args: testCase.args,
-      }), 'signature-invalid')
+      }), 'gateway/signature-invalid')
     }
   })
 
@@ -728,7 +732,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'signature-invalid')
+    }), 'gateway/signature-invalid')
   })
 
   it('requires exact wire fields before invoking business code', async () => {
@@ -739,17 +743,17 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { request: { title: 'ship' } },
-    }), 'arguments-invalid')
+    }), 'gateway/arguments-invalid')
     await expectCode(ctx.typertGateway.invoke({
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true },
-    }), 'arguments-invalid')
+    }), 'gateway/arguments-invalid')
     await expectCode(ctx.typertGateway.invoke({
       namespace: 'goals',
       method: 'create',
       args: [] as unknown as Record<string, unknown>,
-    }), 'arguments-invalid')
+    }), 'gateway/arguments-invalid')
     expect(service.calls).toEqual([])
   })
 
@@ -761,7 +765,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'strictOnly',
       args: { request: { title: 1 } },
-    }), 'input-invalid')
+    }), 'gateway/input-invalid')
 
     service.nextResult = { title: 1 }
     await expect(ctx.typertGateway.invoke({
@@ -799,7 +803,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'passthrough',
       args: { value },
-    }), 'input-invalid')
+    }), 'gateway/input-invalid')
   })
 
   it('admits an omitted SRC field and hands the Host method undefined', async () => {
@@ -823,7 +827,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'passthrough',
       args: { value: cyclic },
-    }), 'input-invalid')
+    }), 'gateway/input-invalid')
 
     const result = new Date(0)
     service.nextResult = result
@@ -855,7 +859,7 @@ describe('TypertGatewayService', () => {
     for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) {
       await expectCode(ctx.typertGateway.invoke({
         namespace: 'goals', method: 'passthrough', args: { value },
-      }), 'input-invalid')
+      }), 'gateway/input-invalid')
     }
   })
 
@@ -871,7 +875,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'create',
       args: { agentId: 'agent-1', request: { title: 'ship' } },
-    }), 'provider-mismatch')
+    }), 'gateway/provider-mismatch')
   })
 
   it('validates binding identity and active method availability', async () => {
@@ -881,7 +885,7 @@ describe('TypertGatewayService', () => {
       namespace: 'wrong-binding',
       method: 'run',
       args: { value: 'ship' },
-    }), 'binding-invalid')
+    }), 'gateway/binding-invalid')
 
     await ctx.plugin(GoalService)
     registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }])
@@ -889,7 +893,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'missing',
       args: { value: 'ship' },
-    }), 'method-unavailable')
+    }), 'gateway/method-unavailable')
   })
 
   it('requires a visible binding and supports explicitly provided plain Services', async () => {
@@ -904,7 +908,7 @@ describe('TypertGatewayService', () => {
     }])
     await expectCode(ctx.typertGateway.invoke({
       namespace: 'no-binding', method: 'run', args: { value: 'ship' },
-    }), 'binding-invalid')
+    }), 'gateway/binding-invalid')
 
     const plain: {
       typertRemote?: ReturnType<typeof bindTypertRemote>
@@ -941,7 +945,7 @@ describe('TypertGatewayService', () => {
     try {
       await expectCode(ctx.typertGateway.invoke({
         namespace: 'missing-method', method: 'run', args: { value: 'ship' },
-      }), 'method-unavailable')
+      }), 'gateway/method-unavailable')
     } finally {
       Object.defineProperty(MissingMethodService.prototype, 'run', descriptor)
     }
@@ -965,7 +969,7 @@ describe('TypertGatewayService', () => {
       namespace: 'goals',
       method: 'absent',
       args: {},
-    }), 'invocation-unavailable')
+    }), 'gateway/invocation-unavailable')
   })
 
   it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => {
@@ -1002,7 +1006,7 @@ describe('TypertGatewayService', () => {
     const invalid = await handler('goals/create', { invalid: true }, signal)
     expect(invalid).toMatchObject({
       ok: false,
-      error: { code: 'internal' },
+      error: { code: 'gateway/internal' },
     })
     if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
     expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
@@ -1018,13 +1022,13 @@ describe('TypertGatewayService', () => {
 
     for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
       const result = await handler(endpoint, { args: {} }, signal)
-      expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
+      expect(result).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
       if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded')
       expect(result.error.message).toContain('invalid Remote endpoint')
     }
     for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) {
       const result = await handler('goals/create', payload, signal)
-      expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
+      expect(result).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
       if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
       expect(result.error.message).toContain('plain-object args field')
     }
@@ -1036,7 +1040,7 @@ describe('TypertGatewayService', () => {
       new AbortController().signal,
     )).resolves.toEqual({
       ok: false,
-      error: { code: 'internal', message: 'non-error failure', details: {} },
+      error: { code: 'gateway/internal', message: 'non-error failure', details: {} },
     })
 
     // A business rejection observed while the carrier signal is already aborted
@@ -1051,7 +1055,7 @@ describe('TypertGatewayService', () => {
     )).resolves.toEqual({
       ok: false,
       error: {
-        code: 'cancelled',
+        code: 'gateway/cancelled',
         message: 'Remote invocation "goals/fail" was aborted',
         details: {},
       },
@@ -1075,7 +1079,7 @@ describe('TypertGatewayService', () => {
       args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } },
     }
     const inactive = await handler('$events/result', result, new AbortController().signal)
-    expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } })
+    expect(inactive).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
     if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded')
     expect(inactive.error.message).toContain('identifies no active event stream')
 
@@ -1098,7 +1102,7 @@ describe('TypertGatewayService', () => {
 
     for (const payload of [null, [], {}, { other: {} }]) {
       const invalid = await handler('$events/result', payload, carrier.signal)
-      expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } })
+      expect(invalid).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
       if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded')
       expect(invalid.error.message).toContain('requires exactly one plain-object args field')
     }
@@ -1122,13 +1126,13 @@ describe('TypertGatewayService', () => {
     await ctx.plugin(GoalService)
     registerStrict(ctx, [createDescriptor()])
     const failure = {
-      code: 'agent-busy',
+      code: 'session/agent-busy',
       message: 'session is owned by subagent routing',
       details: { reason: 'use subagent delivery for this child session' },
     }
     ctx.typert.lookups.register('gatewayFixture', {
       ...agentLookup({ id: 'agent-1' }),
-      resolve: () => { throw new TypertLookupFailure(failure) },
+      resolve: () => { throw new RemoteError('session/agent-busy', failure.message, failure.details) },
     })
     const handler = rawConnection(ctx).handler
     if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
@@ -1225,7 +1229,7 @@ describe('TypertGatewayService', () => {
         rpcId: 'rpc-invalid',
         result: {
           ok: false,
-          error: { code: 'internal' },
+          error: { code: 'gateway/internal' },
         },
       })
       expect(JSON.stringify(invalidBody)).toContain('plain-object args field')
@@ -1249,7 +1253,7 @@ describe('TypertGatewayService', () => {
         rpcId: 'rpc-withdrawn',
         result: {
           ok: false,
-          error: { code: 'internal' },
+          error: { code: 'gateway/definition-unavailable' },
         },
       })
       expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn')

+ 1 - 0
packages/api/gateway/tsconfig.client.json

@@ -12,6 +12,7 @@
     "src/client/remote-stream.ts",
     "src/client/snapshot-stream.ts",
     "src/client/stream-client.ts",
+    "src/remote-error-codes.ts",
     "src/stream-protocol.ts"
   ],
   "references": [

+ 1 - 0
packages/api/gateway/tsconfig.host.json

@@ -8,6 +8,7 @@
   "files": [
     "src/index.ts",
     "src/invariant.ts",
+    "src/remote-error-codes.ts",
     "src/stream-protocol.ts",
     "src/stream-server.ts",
     "src/types.ts"

+ 10 - 17
packages/api/remotes/src/client/index.ts

@@ -56,7 +56,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
 export type {
   ConnectionHandle, ConnectionSinks, ContentBlock,
   MessageId,
-  RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
+  RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
   StreamChunk,
 } from '@deepseek-ai/dsh-client-connection/client'
 export type {} from '@deepseek-ai/dsh-api-gateway/client'
@@ -112,7 +112,7 @@ export type {
 } from '@deepseek-ai/dsh-settings/types'
 // Provider registry and discovery vocabulary for the llm namespace.
 export type {
-  LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryError,
+  LlmConfigurableProvider, LlmDiscoveredModel,
   LlmModelDiscoveryRequest, LlmProviderInfo,
 } from '@deepseek-ai/dsh-llm/types'
 // Reference-discovery result vocabulary for the fileReferences and
@@ -120,21 +120,14 @@ export type {
 export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
 export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types'
 
-/** Failure vocabulary exposed by the assembled Client data layer. */
-export type ClientFailure =
-  | import('@deepseek-ai/dsh-client-connection/client').RpcError
-  | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError
-  | import('@deepseek-ai/dsh-api-session-controller/types').SessionError
-  | import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError
-  | import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError
-  | import('@deepseek-ai/dsh-llm/types').LlmModelDiscoveryError
-  | import('@deepseek-ai/dsh-subagent/client').SubagentControlError
-  | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
-
-/** Success or failure returned by Client operations spanning both API families. */
-export type ClientResult<T> =
-  | { readonly ok: true; readonly value: T }
-  | { readonly ok: false; readonly error: ClientFailure }
+// The Remote failure vocabulary, re-exported so business packages keep naming
+// this assembly alone. Types only: a value export would make spec imports load
+// this module's owner /remote artifacts; specs take RemoteError from
+// dsh-client-test-runtime instead.
+export type {
+  RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure, RemoteResult,
+} from '@deepseek-ai/dsh-typert-protocol'
+export type { RemoteHostFacts } from '@deepseek-ai/dsh-api-gateway/client'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {

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

@@ -99,6 +99,7 @@
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-util-time": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
@@ -137,6 +138,7 @@
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-util-time": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",

+ 17 - 26
packages/api/session-controller/src/agent.ts

@@ -11,9 +11,9 @@ import type {} from '@deepseek-ai/dsh-agent-presets'
 import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
-import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-typert-registry'
-import type { ModelSelection, SessionError } from './types.ts'
+import type { ModelSelection } from './types.ts'
 
 /** Cold Session identity absent from persistence. */
 export class ApiSessionNotFound extends Error {}
@@ -57,10 +57,7 @@ export class ApiSessionPresetConflict extends Error {
 }
 
 /** Failures produced while resolving one ordinary Session identity to its live Agent. */
-export type ApiSessionAgentError = Extract<
-  SessionError,
-  { readonly code: 'session-not-found' | 'agent-busy' | 'internal' }
->
+export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'gateway/internal'>
 
 /** Result of resolving one ordinary Session identity to its live Agent. */
 export type ApiSessionAgentResult =
@@ -97,11 +94,11 @@ export function hasApiSessionSubagentOwner(
  * @returns a stable Session-domain failure.
  */
 export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
-  return {
-    code: 'agent-busy',
-    message: `session "${sessionId}" is owned by subagent routing`,
-    details: { reason: 'use subagent delivery for this child session' },
-  }
+  return new RemoteError(
+    'session/agent-busy',
+    `session "${sessionId}" is owned by subagent routing`,
+    { reason: 'use subagent delivery for this child session' },
+  )
 }
 
 /**
@@ -145,17 +142,17 @@ export class ApiSessionAgentController {
   constructor(private readonly ctx: Context) {
     ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
       const found = await this.resolveAgent(sessionId)
-      if ('error' in found) throw new TypertLookupFailure(found.error)
+      if ('error' in found) throw found.error
       return found.agent
     })
     ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
       const found = await this.resolveAgent(sessionId)
-      if ('error' in found) throw new TypertLookupFailure(found.error)
+      if ('error' in found) throw found.error
       return found.agent.session
     })
     ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
       const found = await this.resolveAgent(sessionId)
-      if ('error' in found) throw new TypertLookupFailure(found.error)
+      if ('error' in found) throw found.error
       return found.agent.ctx
     })
   }
@@ -198,13 +195,7 @@ export class ApiSessionAgentController {
       return { agent: await resume }
     } catch (error: unknown) {
       if (error instanceof ApiSessionNotFound) {
-        return {
-          error: {
-            code: 'session-not-found',
-            message: error.message,
-            details: { sessionId },
-          },
-        }
+        return { error: new RemoteError('session/not-found', error.message, { sessionId }) }
       }
       if (error instanceof ApiSessionSubagentOwnership) {
         return { error: apiSessionSubagentOwnershipError(error.sessionId) }
@@ -216,11 +207,11 @@ export class ApiSessionAgentController {
         return { error: apiSessionSubagentOwnershipError(sessionId) }
       }
       return {
-        error: {
-          code: 'internal',
-          message: `resume failed for session "${sessionId}": ${String(error)}`,
-          details: {},
-        },
+        error: new RemoteError(
+          'gateway/internal',
+          `resume failed for session "${sessionId}": ${String(error)}`,
+          {},
+        ),
       }
     }
   }

+ 0 - 29
packages/api/session-controller/src/client/contract/result.ts

@@ -1,29 +0,0 @@
-/** Client operation results spanning the Session and subagent Remote calls. */
-
-import type { RpcError } from '@deepseek-ai/dsh-client-connection/client'
-import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client'
-import type { SessionError } from '../../types.ts'
-
-/** Failure surfaced by the Client Session object layer. */
-export type ClientFailure = RpcError | SessionError | SubagentControlError
-
-/** Success or failure returned by a Client Session operation. */
-export type ClientResult<T> =
-  | { readonly ok: true; readonly value: T }
-  | { readonly ok: false; readonly error: ClientFailure }
-
-/**
- * Fold a rejected carrier operation into the Client Session failure vocabulary.
- * @param error - rejection from a Remote or local carrier call.
- * @returns the failure branch of a Client Session result.
- */
-export function transportResult<T>(error: unknown): ClientResult<T> {
-  return {
-    ok: false,
-    error: {
-      code: 'internal',
-      message: error instanceof Error ? error.message : String(error),
-      details: {},
-    },
-  }
-}

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

@@ -13,7 +13,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
-import type { ClientResult } from './result.ts'
 import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
 
 /**
@@ -84,7 +83,7 @@ export interface ISession {
     mode: 'queue' | 'steer',
     signal?: AbortSignal,
     requestId?: SessionRequestId,
-  ): Promise<ClientResult<{ accepted: true }>>
+  ): Promise<RemoteResult<{ accepted: true }>>
   /**
    * Resolve one durable image referenced by this session.
    * @param attachmentId - opaque id found in the folded session log.
@@ -92,27 +91,27 @@ export interface ISession {
    */
   readAttachment(
     attachmentId: AttachmentIdType,
-  ): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
+  ): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
   /**
    * Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param action - requested queue operation.
    * @returns acceptance, or a business/transport error.
    */
-  updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>>
+  updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{ accepted: true }>>
   /**
    * Cancel the running turn. Pending queued work remains and resumes in FIFO
    * order after the Host reaches cancellation quiescence.
    * @returns acceptance, or the business error.
    */
-  cancel(): Promise<ClientResult<{ accepted: true }>>
+  cancel(): Promise<RemoteResult<{ accepted: true }>>
   /**
    * Rename this session (explicit user title; pins it against automatic
    * regeneration).
    * @param title - raw title text (the host normalizes acceptance).
    * @returns the normalized accepted title and its event seq, or the business error.
    */
-  rename(title: string): Promise<ClientResult<{ title: string; seq: number }>>
+  rename(title: string): Promise<RemoteResult<{ title: string; seq: number }>>
   /**
    * Extend the history window backwards (older messages pagination).
    * @returns completion; failures land in snapshot.openState/loadingOlder.

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

@@ -8,10 +8,10 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentContext } from '../scope.ts'
 import type { SessionSearchResultItem } from '../sessions/manager.ts'
 import type { SessionBinding, SessionListState } from '../sessions/service.ts'
-import type { ClientResult } from './result.ts'
 import type { SessionFace } from './session.ts'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 
@@ -83,7 +83,7 @@ export interface ISessions {
   search(
     query: string,
     signal: AbortSignal,
-  ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
+  ): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
   /**
    * Fork a session from a completed-turn prefix of the source; on resolution
    * the child is in the list store and `open()` can target it.

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

@@ -3,8 +3,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
+import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionRequestId } from '../../types.ts'
-import type { ClientFailure } from './result.ts'
 
 /** One transient inbox occurrence from the authoritative queue snapshot. */
 export interface QueuedMessage {
@@ -53,7 +53,7 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
 /** Send/stop failure surfaced by Session consumers. */
 export interface PromptError {
   readonly op: 'send' | 'stop'
-  readonly error: ClientFailure
+  readonly error: RemoteFailure
 }
 
 /** Immutable Session lifecycle and control snapshot. */
@@ -70,7 +70,7 @@ export interface SessionSnapshot {
   } | null
   readonly removed: boolean
   readonly openState: OpenState
-  readonly openError: ClientFailure | null
+  readonly openError: RemoteFailure | null
   readonly hasMore: boolean
   readonly loadingOlder: boolean
   readonly promptError: PromptError | null

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

@@ -2,7 +2,6 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent/types'
-import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import { createSessionControlStream } from './transport.ts'
 import { ClientSessions } from './sessions/service.ts'
 import type { SessionRemotes } from './sessions/remotes.ts'
@@ -13,7 +12,6 @@ export {
   SessionEventStream,
   SESSION_SEARCH_RESULT_LIMIT,
   SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
-  sessionStreamFailure,
 } from './transport.ts'
 export type {
   ClientSessionPageRequest,
@@ -66,7 +64,6 @@ export type {
   QueuedMessage,
   SessionSnapshot,
 } from './contract/snapshot.ts'
-export type { ClientFailure, ClientResult } from './contract/result.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -75,9 +72,8 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
-/** Required wire, Remote, and Context projection services. */
+/** Required Remote and Context projection services. */
 export const inject = [
-  'connection',
   'typert',
   'remote',
   'remote.commands',
@@ -90,7 +86,6 @@ export const inject = [
  * @param ctx - Client Cordis context.
  */
 export function apply(ctx: Context): void {
-  const connection = ctx.get('connection') as ConnectionHandle
   const remotes = ctx.remote as unknown as SessionRemotes
   const sessions = new ClientSessions(ctx, remotes)
   ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
@@ -111,7 +106,7 @@ export function apply(ctx: Context): void {
   })
   control.start()
   ctx.on('connection/reset', () => { sessions.handleConnected() })
-  if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected()
+  if (ctx.remote.$host.home !== undefined) sessions.handleConnected()
   ctx.typert.contexts.registerClient('agent', {
     identity: candidate => sessions.scopeOf(candidate),
     resolve: sessionId => sessions.resolveAgentScope(sessionId),

+ 60 - 81
packages/api/session-controller/src/client/sessions/manager.ts

@@ -9,13 +9,12 @@ import type {
   SessionControlBaseline,
   SessionControlFrame,
   SessionQueuedItem,
-  SessionError,
   SessionSummary,
   SessionJob as JobView,
 } from '../../types.ts'
 import { mergeOrderedBaseline } from '../ordered-baseline.ts'
-import type { ClientFailure, ClientResult } from '../contract/result.ts'
-import { transportResult } from '../contract/result.ts'
+import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
 import { flattenLineage } from './lineage.ts'
 // Type-only merge edge: the title domain's client-namespace outlet declares
@@ -51,7 +50,7 @@ export interface SessionListSnapshot {
   state: 'idle' | 'loading' | 'error'
   /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
   phase: SessionListPhase
-  error: ClientFailure | null
+  error: RemoteFailure | null
   subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
   /** Background jobs per session; an absent key is an empty set. */
   jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
@@ -63,7 +62,7 @@ export type SubagentCatalogSnapshot = Omit<SubagentCatalog, 'parentAvailable'> &
   /** Absent until the first successful catalog read. */
   readonly parentAvailable?: boolean
   state: 'loading' | 'ready' | 'error'
-  error: ClientFailure | null
+  error: RemoteFailure | null
 }
 
 function catalogAvailability(parentAvailable: boolean | undefined): {
@@ -112,7 +111,7 @@ export class SessionManager {
   private listState: 'idle' | 'loading' | 'error' = 'idle'
   /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
   private listPhase: SessionListPhase = 'pending'
-  private listError: ClientFailure | null = null
+  private listError: RemoteFailure | null = null
   private listInflight: Promise<void> | null = null
   /** Mutations arriving after a list request starts are replayed over its response. */
   private listMutations: SessionListMutation[] | null = null
@@ -366,7 +365,7 @@ export class SessionManager {
     this.notifier.markDirty()
     const operation = (async () => {
       try {
-        const result = toSessionResult(await this.remote.subagents.list(parentSessionId))
+        const result = await this.remote.subagents.list(parentSessionId)
         if (result.ok) {
           const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
             ?? result.value.parentAvailable
@@ -395,7 +394,7 @@ export class SessionManager {
           })
         }
       } catch (error: unknown) {
-        const folded = transportResult<never>(error)
+        if (!isRemoteFailure(error)) throw error
         this.catalogs.set(parentSessionId, {
           entries: this.withCatalogMutations(
             previous?.entries ?? [], expandableRows, activityRows,
@@ -405,7 +404,7 @@ export class SessionManager {
               ?? previous?.parentAvailable,
           ),
           state: 'error',
-          error: folded.ok ? null : folded.error,
+          error,
         })
       } finally {
         this.catalogInflight.delete(parentSessionId)
@@ -457,7 +456,7 @@ export class SessionManager {
     this.notifier.markDirty()
     this.listInflight = (async () => {
       try {
-        const result = toSessionResult(await this.remote.session.list({}))
+        const result = await this.remote.session.list({})
         if (result.ok) {
           const baseline: SessionSummary[] = this.listPhase === 'pending'
             ? [...result.value.items]
@@ -506,10 +505,9 @@ export class SessionManager {
           this.listError = result.error
         }
       } catch (error) {
+        if (!isRemoteFailure(error)) throw error
         this.listState = 'error'
-        const folded = transportResult<never>(error)
-        /* v8 ignore next -- the `? null` arm is unreachable: transportResult always returns ok:false. */
-        this.listError = folded.ok ? null : folded.error
+        this.listError = error
       } finally {
         this.listMutations = null
         this.listInflight = null
@@ -529,19 +527,15 @@ export class SessionManager {
   async search(
     query: string,
     signal: AbortSignal,
-  ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
-    try {
-      const result = toSessionResult(await this.remote.session.search({ query }, signal))
-      if (!result.ok) return result
-      return {
-        ok: true,
-        value: {
-          items: [...result.value.items],
-          hasMore: result.value.hasMore,
-        },
-      }
-    } catch (error: unknown) {
-      return transportResult(error)
+  ): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
+    const result = await this.remote.session.search({ query }, signal)
+    if (!result.ok) return result
+    return {
+      ok: true,
+      value: {
+        items: [...result.value.items],
+        hasMore: result.value.hasMore,
+      },
     }
   }
 
@@ -558,36 +552,32 @@ export class SessionManager {
       cwd?: string
       sessionId?: SessionId
     } = {},
-  ): Promise<ClientResult<{ sessionId: SessionId }>> {
-    try {
-      const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
-      const payload = opts.workspaceId !== undefined
-        ? { workspaceId: opts.workspaceId, ...shared }
-        : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
-      const result = toSessionResult(await this.remote.session.create(payload))
-      if (result.ok) {
+  ): Promise<RemoteResult<{ sessionId: SessionId }>> {
+    const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
+    const payload = opts.workspaceId !== undefined
+      ? { workspaceId: opts.workspaceId, ...shared }
+      : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
+    const result = await this.remote.session.create(payload)
+    if (result.ok) {
+      this.recordMutation({ kind: 'upsert', summary: {
+        sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
+        ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
+      } })
+    } else {
+      const publishedSessionId = workspaceAttachSessionId(result.error)
+      // Publication precedes attachment. The error's id is a real Session,
+      // so expose it immediately as Ungrouped while the caller keeps the
+      // prompt buffer and decides whether to retry attachment.
+      if (publishedSessionId !== undefined) {
         this.recordMutation({ kind: 'upsert', summary: {
-          sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
-          ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
+          sessionId: publishedSessionId,
+          updatedAt: Date.now(),
+          running: false,
+          blank: true,
         } })
-      } else {
-        const publishedSessionId = workspaceAttachSessionId(result.error)
-        // Publication precedes attachment. The error's id is a real Session,
-        // so expose it immediately as Ungrouped while the caller keeps the
-        // prompt buffer and decides whether to retry attachment.
-        if (publishedSessionId !== undefined) {
-          this.recordMutation({ kind: 'upsert', summary: {
-            sessionId: publishedSessionId,
-            updatedAt: Date.now(),
-            running: false,
-            blank: true,
-          } })
-        }
       }
-      return result
-    } catch (error) {
-      return transportResult(error)
     }
+    return result
   }
 
   /**
@@ -601,27 +591,23 @@ export class SessionManager {
    */
   async fork(
     opts: { sessionId: SessionId; atSeq?: number },
-  ): Promise<ClientResult<{ sessionId: SessionId }>> {
-    try {
-      const source = this.summaries.find(s => s.sessionId === opts.sessionId)
-      const result = toSessionResult(await this.remote.session.fork({
-        sessionId: opts.sessionId,
-        ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
-      }))
-      const childId = result.ok
-        ? result.value.sessionId
-        : workspaceAttachSessionId(result.error)
-      if (childId !== undefined) {
-        this.recordMutation({ kind: 'upsert', summary: {
-          sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
-          parentSessionId: opts.sessionId,
-          ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
-        } })
-      }
-      return result
-    } catch (error) {
-      return transportResult(error)
+  ): Promise<RemoteResult<{ sessionId: SessionId }>> {
+    const source = this.summaries.find(s => s.sessionId === opts.sessionId)
+    const result = await this.remote.session.fork({
+      sessionId: opts.sessionId,
+      ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
+    })
+    const childId = result.ok
+      ? result.value.sessionId
+      : workspaceAttachSessionId(result.error)
+    if (childId !== undefined) {
+      this.recordMutation({ kind: 'upsert', summary: {
+        sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
+        parentSessionId: opts.sessionId,
+        ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
+      } })
     }
+    return result
   }
 
   /**
@@ -1010,13 +996,6 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
 }
 
 /** Temporary source-plane bridge while the Host contract and client project build independently. */
-function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined {
-  return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined
-}
-
-/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
-function toSessionResult<T>(
-  result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult<T>,
-): ClientResult<T> {
-  return result.ok ? result : { ok: false, error: result.error as SessionError }
+function workspaceAttachSessionId(error: RemoteFailure): SessionId | undefined {
+  return error.code === 'session/workspace-attach-failed' ? error.details.sessionId : undefined
 }

+ 4 - 4
packages/api/session-controller/src/client/sessions/service.ts

@@ -25,7 +25,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t
 import {
   createSnapshotStore, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
-import type { ClientFailure, ClientResult } from '../contract/result.ts'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionEventSource } from '../contract/events.ts'
 import type { SessionFace } from '../contract/session.ts'
 import type { AgentContext, ISessions } from '../contract/sessions.ts'
@@ -101,7 +101,7 @@ export class SessionCreateError extends Error {
    * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
    */
   constructor(
-    readonly rpcError: ClientFailure,
+    readonly rpcError: RemoteFailure,
     readonly requestedSessionId: SessionId | undefined,
   ) {
     super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
@@ -117,7 +117,7 @@ export class SessionForkError extends Error {
    * @param sourceSessionId - the session the fork was cut from.
    */
   constructor(
-    readonly rpcError: ClientFailure,
+    readonly rpcError: RemoteFailure,
     readonly sourceSessionId: SessionId,
   ) {
     super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
@@ -335,7 +335,7 @@ export class ClientSessions implements ISessions {
   search(
     query: string,
     signal: AbortSignal,
-  ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
+  ): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
     return this.manager.search(query, signal)
   }
 

+ 57 - 132
packages/api/session-controller/src/client/sessions/session.ts

@@ -6,10 +6,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import {
-  SessionEventStream,
-  sessionStreamFailure,
-} from '../transport.ts'
+import { SessionEventStream } from '../transport.ts'
 import type { SessionJournalChange } from '../transport.ts'
 import type {
   PromptContentPart,
@@ -18,10 +15,7 @@ import type {
   SessionControlFrame,
   SessionQueuedItem,
   SessionRequestId,
-  SessionError,
 } from '../../types.ts'
-import type { ClientFailure, ClientResult } from '../contract/result.ts'
-import { transportResult } from '../contract/result.ts'
 import type {
   BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
 } from '../contract/session.ts'
@@ -33,7 +27,8 @@ import type {
   SessionEventLikeEntry, SessionLiveEventEntry,
 } from '../contract/events.ts'
 import { Notifier } from './notifier.ts'
-import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionRemotes } from './remotes.ts'
 import { ProjectionValueStore } from './projection-store.ts'
 import type { ProjectionsBaseline } from './projection-store.ts'
@@ -77,7 +72,7 @@ export class Session implements SessionFace {
   private baseSeq = 0
   private hasMore = false
   private openState: OpenState = 'cold'
-  private openError: ClientFailure | null = null
+  private openError: RemoteFailure | null = null
   private openPromise: Promise<void> | null = null
   /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
    *  passes drop all writes once the generation moves on. */
@@ -214,7 +209,7 @@ export class Session implements SessionFace {
     mode: 'queue' | 'steer',
     signal?: AbortSignal,
     requestId?: SessionRequestId,
-  ): Promise<ClientResult<{ accepted: true }>> {
+  ): Promise<RemoteResult<{ accepted: true }>> {
     this.promptError = null
     this.lastAgentError = null
     // Synchronous, before the first await: the blank → engaging edge must be
@@ -223,52 +218,26 @@ export class Session implements SessionFace {
     this.promptAttempted = true
     if (this.blankBit) this.firstPromptPendingTurn = true
     this.notifier.markDirty()
-    let result: ClientResult<{ accepted: true }>
-    try {
-      if (this.address === undefined) {
-        const clientTimeZone = resolvedClientTimeZone()
-        result = toSessionResult(await this.remote.session.prompt({
-          requestId: requestId ?? randomUUID() as SessionRequestId,
-          sessionId: this.sessionId,
-          mode,
-          content,
-          clientTimeZone,
-        }, signal))
-      } else if (this.address.mode === 'one-shot') {
-        result = {
-          ok: false,
-          error: {
-            code: 'subagent-not-resumable',
-            message: 'one-shot subagent conversations are read-only',
-            details: { childSessionId: this.address.childSessionId },
-          },
-        }
-      } else {
-        if (content.some(part => part.type === 'image')) {
-          result = {
-            ok: false,
-            error: {
-              code: 'attachment-error',
-              message: 'Image input is unavailable for subagent continuations.',
-              details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
-            },
-          }
-        } else {
-          const routed = toSessionResult(await this.remote.subagents.prompt({
-            requestId: randomUUID() as SessionRequestId,
-            parentSessionId: this.address.parentSessionId,
-            childSessionId: this.address.childSessionId,
-            mode: this.address.mode,
-            content: content.flatMap(part => part.type === 'text'
-              ? [{ type: 'text' as const, text: part.text }]
-              : []),
-            clientTimeZone: resolvedClientTimeZone(),
-          }, signal))
-          result = routed.ok ? { ok: true, value: { accepted: true } } : routed
-        }
-      }
-    } catch (error) {
-      result = transportResult(error)
+    let result: RemoteResult<{ accepted: true }>
+    if (this.address === undefined) {
+      const clientTimeZone = resolvedClientTimeZone()
+      result = await this.remote.session.prompt({
+        requestId: requestId ?? randomUUID() as SessionRequestId,
+        sessionId: this.sessionId,
+        mode,
+        content,
+        clientTimeZone,
+      }, signal)
+    } else {
+      const routed = await this.remote.subagents.prompt({
+        requestId: randomUUID() as SessionRequestId,
+        parentSessionId: this.address.parentSessionId,
+        childSessionId: this.address.childSessionId,
+        mode: 'continuable',
+        content,
+        clientTimeZone: resolvedClientTimeZone(),
+      }, signal)
+      result = routed.ok ? { ok: true, value: { accepted: true } } : routed
     }
     if (!result.ok) {
       if (requestId !== undefined) this.retireFailedSubmission(requestId)
@@ -299,66 +268,38 @@ export class Session implements SessionFace {
    */
   async readAttachment(
     attachmentId: AttachmentIdType,
-  ): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
-    try {
-      const result = await this.remote.session.attachment({
-        sessionId: this.sessionId,
-        attachmentId,
-      })
-      if (!result.ok) return toSessionResult(result)
-      const binary = atob(result.value.data)
-      const data = Uint8Array.from(binary, char => char.charCodeAt(0))
-      return { ok: true, value: { attachment: result.value.attachment, data } }
-    } catch (error) {
-      return transportResult(error)
-    }
+  ): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
+    const result = await this.remote.session.attachment({
+      sessionId: this.sessionId,
+      attachmentId,
+    })
+    if (!result.ok) return result
+    const binary = atob(result.value.data)
+    const data = Uint8Array.from(binary, char => char.charCodeAt(0))
+    return { ok: true, value: { attachment: result.value.attachment, data } }
   }
 
   /** Apply one operation to a still-pending queue occurrence. */
-  async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
-    try {
-      return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }))
-    } catch (error) {
-      return transportResult(error)
-    }
+  async updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{ accepted: true }>> {
+    return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })
   }
 
   /**
    * Stop the active turn while the Host preserves pending inbox work; failures
-   * land in promptError (same error-strip display slot). A continuable
-   * subagent address routes through `subagents.interruptByParent`, whose durable
-   * parent-address authority works without a live parent Agent; a one-shot
-   * address stays uncancellable (the UI offers no stop action, so this arm is
-   * defensive).
+   * land in promptError (same error-strip display slot). A subagent address
+   * routes through `subagents.interruptByParent`, whose durable parent-address
+   * authority works without a live parent Agent.
    * @returns the cancel result.
    */
-  async cancel(): Promise<ClientResult<{ accepted: true }>> {
+  async cancel(): Promise<RemoteResult<{ accepted: true }>> {
     const address = this.address
-    if (address !== undefined && address.mode === 'one-shot') {
-      const result: ClientResult<{ accepted: true }> = {
-        ok: false,
-        error: {
-          code: 'subagent-delivery-unavailable',
-          message: 'subagent activation cancellation is unavailable',
-          details: { childSessionId: address.childSessionId },
-        },
-      }
-      this.promptError = { op: 'stop', error: result.error }
-      this.notifier.markDirty()
-      return result
-    }
-    let result: ClientResult<{ accepted: true }>
-    try {
-      result = address !== undefined
-        ? toSessionResult(await this.remote.subagents.interruptByParent(
-          address.childSessionId,
-          address.parentSessionId,
-          address.mode,
-        ))
-        : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
-    } catch (error) {
-      result = transportResult(error)
-    }
+    const result = address !== undefined
+      ? await this.remote.subagents.interruptByParent(
+        address.childSessionId,
+        address.parentSessionId,
+        'continuable',
+      )
+      : await this.remote.session.cancel({ sessionId: this.sessionId })
     if (!result.ok) {
       this.promptError = { op: 'stop', error: result.error }
       this.notifier.markDirty()
@@ -375,14 +316,10 @@ export class Session implements SessionFace {
    * @param title - raw title text (the host normalizes acceptance).
    * @returns the rename result (normalized accepted title + title event seq).
    */
-  async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
-    try {
-      const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title }))
-      if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
-      return result
-    } catch (error) {
-      return transportResult(error)
-    }
+  async rename(title: string): Promise<RemoteResult<{ title: string; seq: number }>> {
+    const result = await this.remote.session.rename({ sessionId: this.sessionId, title })
+    if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
+    return result
   }
 
   /**
@@ -390,7 +327,7 @@ export class Session implements SessionFace {
    * admission semantics (the host executor durably logs the lifecycle;
    * outcomes render as flow nodes, never as a response echo).
    * @param line - the full command line, leading slash included.
-   * @returns the admission result, or the error branch on transport failure.
+   * @returns the admission result.
    */
   async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
     const result = await this.remote.commands.execute(this.sessionId, line, [])
@@ -420,7 +357,7 @@ export class Session implements SessionFace {
     try {
       await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
     } catch (error) {
-      if (sessionStreamFailure(error) === undefined) {
+      if (!isRemoteFailure(error)) {
         console.error('[session-controller] loadOlder failed:', error)
       }
     } finally {
@@ -600,9 +537,10 @@ export class Session implements SessionFace {
       this.openState = 'open'
     } catch (error) {
       if (generation !== this.openGeneration || this.events !== events) return
+      if (!isRemoteFailure(error)) throw error
       this.events = undefined
       this.openState = 'error'
-      this.openError = openFailure(error)
+      this.openError = error
     } finally {
       if (generation === this.openGeneration) this.notifier.markDirty()
     }
@@ -714,11 +652,12 @@ export class Session implements SessionFace {
   /** Publish a terminal background failure only while this stream still owns the Session. */
   private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
     if (generation !== this.openGeneration || this.events !== events) return
+    if (!isRemoteFailure(error)) throw error
     this.openGeneration++
     this.events = undefined
     this.openPromise = null
     this.openState = 'error'
-    this.openError = openFailure(error)
+    this.openError = error
     void events.dispose()
     this.notifier.markDirty()
   }
@@ -774,17 +713,3 @@ function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
   }
   return refs
 }
-
-/** Convert a terminal Session stream failure to the Client error vocabulary. */
-function openFailure(error: unknown): ClientFailure {
-  const failure = sessionStreamFailure(error)
-  if (failure !== undefined) return failure as SessionError
-  const folded = transportResult<never>(error)
-  /* v8 ignore next -- transportResult never returns an ok result. */
-  if (folded.ok) throw new Error('transportResult returned an unexpected success')
-  return folded.error
-}
-/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
-function toSessionResult<T>(result: RemoteResult<T>): ClientResult<T> {
-  return result.ok ? result : { ok: false, error: result.error as SessionError }
-}

+ 4 - 23
packages/api/session-controller/src/client/transport.ts

@@ -1,12 +1,10 @@
 /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */
 
 import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
-import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import {
   RemoteJournalStream,
   RemoteSnapshotStream,
   RemoteStreamCarrierError,
-  RemoteStreamError,
   type ClientRemote,
   type RemoteJournalChange,
   type RemoteJournalFrame,
@@ -25,6 +23,7 @@ import {
   historyRecordLastSeq,
 } from './sessions/history-records.ts'
 import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts'
+import type { SessionRemotes } from './sessions/remotes.ts'
 
 export {
   SESSION_SEARCH_RESULT_LIMIT,
@@ -80,8 +79,6 @@ export type SessionControlStream = RemoteSnapshotStream<
   SessionControlDeltaFrame
 >
 
-type SessionStreamRemote = Pick<ClientRemote, '$stream' | 'session'>
-
 /** Domain sinks used by the Host-wide Session control stream. */
 export interface SessionControlStreamOptions {
   /** Apply a complete baseline or one later update. */
@@ -109,7 +106,7 @@ export interface SessionEventStreamOptions {
  * @returns an unstarted stream owned by the Client Session runtime.
  */
 export function createSessionControlStream(
-  remote: SessionStreamRemote,
+  remote: SessionRemotes,
   options: SessionControlStreamOptions,
 ): SessionControlStream {
   const stream = remote.$stream<SessionControlFrame>({
@@ -142,7 +139,7 @@ export class SessionEventStream extends RemoteJournalStream<
    * @param options - Session event-window destinations.
    */
   constructor(
-    private readonly remote: SessionStreamRemote,
+    private readonly remote: SessionRemotes,
     private readonly address: SessionAddress,
     options: SessionEventStreamOptions,
   ) {
@@ -198,13 +195,7 @@ export class SessionEventStream extends RemoteJournalStream<
       { address: this.address, throughSeq, ...request },
       signal,
     )
-    if (!result.ok) {
-      throw new RemoteStreamError(
-        result.error.code,
-        result.error.message,
-        result.error.details,
-      )
-    }
+    if (!result.ok) throw result.error
     return result.value
   }
 
@@ -215,13 +206,3 @@ export class SessionEventStream extends RemoteJournalStream<
     return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }
   }
 }
-
-/**
- * Recover a Host Session failure from a Remote stream terminal error.
- * @param error - value thrown while opening or consuming a Session stream.
- * @returns the Host failure, or `undefined` for carrier and local failures.
- */
-export function sessionStreamFailure(error: unknown): RemoteFailure | undefined {
-  if (!(error instanceof RemoteStreamError)) return undefined
-  return { code: error.code, message: error.message, details: error.details }
-}

+ 56 - 87
packages/api/session-controller/src/commands.ts

@@ -3,7 +3,6 @@
 import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
-import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
 import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import {
@@ -14,7 +13,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
+import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type { Workspace } from '@deepseek-ai/dsh-workspace'
 import {
   ApiSessionAgentController,
@@ -71,14 +71,14 @@ export class SessionCommandController {
    */
   async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
     if (request.workspaceId !== undefined && request.cwd !== undefined) {
-      reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {})
+      throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
     }
     const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`)
     let workspace: Workspace | undefined
     if (request.workspaceId !== undefined) {
       workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
       if (workspace === undefined) {
-        reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, {
+        throw new RemoteError('workspace/not-found', `workspace "${request.workspaceId}" not found`, {
           workspaceId: request.workspaceId,
         })
       }
@@ -99,8 +99,8 @@ export class SessionCommandController {
       try {
         await workspace.attachSession(sessionId)
       } catch (error) {
-        reject(
-          'workspace-attach-failed',
+        throw new RemoteError(
+          'session/workspace-attach-failed',
           `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
           { sessionId, workspaceId: workspace.id },
         )
@@ -143,9 +143,9 @@ export class SessionCommandController {
         }
         return { selected: { ...selected } }
       } catch (error) {
-        if (error instanceof TypertRemoteFailure) throw error
-        reject(
-          'model-unavailable',
+        if (remoteErrorOf(error) !== undefined) throw error
+        throw new RemoteError(
+          'session/model-unavailable',
           error instanceof Error ? error.message : String(error),
           { provider: request.provider, model: request.model },
         )
@@ -162,17 +162,17 @@ export class SessionCommandController {
     const agent = await this.resolveAgent(request.sessionId)
     const titles = this.ctx.get('sessionTitle')
     if (titles === undefined) {
-      reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
+      throw new RemoteError('gateway/internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
     }
     try {
       const accepted = titles.rename(agent.session, request.title)
       return { title: accepted.title, seq: accepted.eventSeq }
     } catch (error) {
       if (error instanceof SessionTitleInvalidError) {
-        reject('title-invalid', error.message, { sessionId: request.sessionId })
+        throw new RemoteError('session/title-invalid', error.message, { sessionId: request.sessionId })
       }
-      reject(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         `failed to rename session "${request.sessionId}": ${String(error)}`,
         {},
       )
@@ -187,7 +187,7 @@ export class SessionCommandController {
   async fork(request: SessionForkRequest): Promise<SessionForkValue> {
     if (request.atSeq !== undefined
       && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
-      reject('bad-request', 'atSeq must be a non-negative integer', {})
+      throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative integer', {})
     }
     let observed: SessionObservation
     try {
@@ -195,12 +195,12 @@ export class SessionCommandController {
     } catch (error) {
       if (error instanceof SessionQueryError
         && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
-        reject('session-not-found', `session "${request.sessionId}" not found`, {
+        throw new RemoteError('session/not-found', `session "${request.sessionId}" not found`, {
           sessionId: request.sessionId,
         })
       }
-      reject(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         `fork source unavailable for session "${request.sessionId}": ${String(error)}`,
         {},
       )
@@ -216,8 +216,8 @@ export class SessionCommandController {
         ? source.events.findLast(event => event.type === 'turn/end')
         : undefined)
     if (boundary === undefined) {
-      reject(
-        'fork-unavailable',
+      throw new RemoteError(
+        'session/fork-unavailable',
         atSeq !== undefined && atSeq <= lastSeq
           ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
           : `session "${request.sessionId}" has no completed turn to fork from`,
@@ -230,8 +230,8 @@ export class SessionCommandController {
     try {
       workspace = await this.forkWorkspace(source.header)
     } catch (error) {
-      reject(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`,
         {},
       )
@@ -255,8 +255,8 @@ export class SessionCommandController {
         setup: composition.setup,
       })
     } catch (error) {
-      reject(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         `failed to fork session "${request.sessionId}": ${String(error)}`,
         {},
       )
@@ -265,8 +265,8 @@ export class SessionCommandController {
       try {
         await workspace.attachSession(childId)
       } catch (error) {
-        reject(
-          'workspace-attach-failed',
+        throw new RemoteError(
+          'session/workspace-attach-failed',
           `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
           { sessionId: childId, workspaceId: workspace.id },
         )
@@ -285,8 +285,8 @@ export class SessionCommandController {
       ? undefined
       : canonicalClientTimeZone(request.clientTimeZone)
     if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
-      reject(
-        'invalid-time-zone',
+      throw new RemoteError(
+        'session/invalid-time-zone',
         'clientTimeZone must be UTC or a valid IANA Area/Location name',
         { value: request.clientTimeZone },
       )
@@ -294,8 +294,8 @@ export class SessionCommandController {
     const agent = await this.resolveAgent(request.sessionId)
     const selection = this.agents.selectionFor(agent).current
     if (!routeServed(this.ctx, selection.provider)) {
-      reject(
-        'model-unavailable',
+      throw new RemoteError(
+        'session/model-unavailable',
         `no adapter serves provider "${selection.provider}"; select a model for this session`,
         { provider: selection.provider, model: selection.model },
       )
@@ -312,8 +312,8 @@ export class SessionCommandController {
           const current = this.agents.selectionFor(agent).current
           const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
           if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
-            reject(
-              'attachment-error',
+            throw new RemoteError(
+              'session/attachment-invalid',
               `Model "${current.model}" does not support image input.`,
               { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
             )
@@ -324,11 +324,11 @@ export class SessionCommandController {
         if (request.mode === 'steer') agent.steer(message)
         else agent.followup(message)
       } catch (error) {
-        if (error instanceof TypertRemoteFailure) throw error
+        if (remoteErrorOf(error) !== undefined) throw error
         if (error instanceof AttachmentError) {
-          reject('attachment-error', error.message, { reason: error.code })
+          throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
         }
-        reject('agent-busy', 'prompt rejected', { reason: String(error) })
+        throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
       }
       return { accepted: true }
     }
@@ -346,18 +346,18 @@ export class SessionCommandController {
       source = await this.readSessionState(request.sessionId)
     } catch (error) {
       if (error instanceof ApiSessionNotFound) {
-        reject('session-not-found', error.message, { sessionId: request.sessionId })
+        throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId })
       }
-      reject(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`,
         {},
       )
     }
     const ref = referencedImage(source.events, String(request.attachmentId))
     if (ref === undefined) {
-      reject(
-        'attachment-error',
+      throw new RemoteError(
+        'session/attachment-invalid',
         'Image is not referenced by this session.',
         { reason: 'ATTACHMENT_NOT_REFERENCED' },
       )
@@ -370,9 +370,9 @@ export class SessionCommandController {
       }
     } catch (error) {
       if (error instanceof AttachmentError) {
-        reject('attachment-error', error.message, { reason: error.code })
+        throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
       }
-      reject('internal', 'Unable to read image attachment.', {})
+      throw new RemoteError('gateway/internal', 'Unable to read image attachment.', {})
     }
   }
 
@@ -384,18 +384,18 @@ export class SessionCommandController {
   updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
     if (request.action.kind === 'edit'
       && request.action.content.some(block => block.type !== 'text')) {
-      reject(
-        'attachment-error',
+      throw new RemoteError(
+        'session/attachment-invalid',
         'queue edits accept text content only',
         { reason: 'QUEUE_EDIT_NON_TEXT' },
       )
     }
     const agent = this.ctx.agents.get(request.sessionId)
     if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
-      rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
+      throw apiSessionSubagentOwnershipError(request.sessionId)
     }
     if (agent === undefined) {
-      reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
+      throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
     }
     const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
     const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
@@ -403,11 +403,11 @@ export class SessionCommandController {
       ? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep }
       : { target: 'next-turn' as const, message: nextTurn }
     if (located === undefined) {
-      reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
+      throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
     }
     const { target, message } = located
     if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
-      reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
+      throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
     }
     if (request.action.kind === 'edit') {
       agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
@@ -429,14 +429,14 @@ export class SessionCommandController {
   cancel(request: SessionCancelRequest): SessionCancelValue {
     const agent = this.ctx.agents.get(request.sessionId)
     if (agent === undefined) {
-      reject(
-        'session-not-found',
+      throw new RemoteError(
+        'session/not-found',
         `session "${request.sessionId}" not found (not attached)`,
         { sessionId: request.sessionId },
       )
     }
     if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
-      rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
+      throw apiSessionSubagentOwnershipError(request.sessionId)
     }
     agent.cancel({ kind: 'user' }, { keepInbox: true })
     return { accepted: true }
@@ -444,41 +444,30 @@ export class SessionCommandController {
 
   private async resolveAgent(sessionId: SessionId): Promise<Agent> {
     const found = await this.agents.resolveAgent(sessionId)
-    if ('error' in found) rejectFailure(found.error)
+    if ('error' in found) throw found.error
     return found.agent
   }
 
   private rejectCreation(sessionId: SessionId, error: unknown): never {
+    if (remoteErrorOf(error) !== undefined) throw error
     if (error instanceof ApiSessionPresetConflict) {
-      reject('agent-preset-conflict', error.message, {
+      throw new RemoteError('agent-preset/conflict', error.message, {
         sessionId: error.sessionId,
         requestedPreset: error.requestedPreset,
         ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
       })
     }
-    if (error instanceof UnknownPresetError) {
-      reject('agent-preset-not-found', error.message, {
-        agentPreset: error.presetId,
-        available: [...error.available],
-      })
-    }
-    if (error instanceof PresetMountError) {
-      reject('agent-preset-invalid', error.message, {
-        agentPreset: error.presetId,
-        reason: error.reason,
-      })
-    }
     if (error instanceof ApiSessionCwdConflict) {
-      reject('session-conflict', error.message, {
+      throw new RemoteError('session/conflict', error.message, {
         sessionId: error.sessionId,
         requestedCwd: error.requestedCwd,
         ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }),
       })
     }
     if (error instanceof ApiSessionSubagentOwnership) {
-      rejectFailure(apiSessionSubagentOwnershipError(error.sessionId))
+      throw apiSessionSubagentOwnershipError(error.sessionId)
     }
-    reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {})
+    throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {})
   }
 
   private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
@@ -503,14 +492,6 @@ export class SessionCommandController {
   }
 }
 
-function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never {
-  throw new TypertRemoteFailure(error)
-}
-
-function reject(code: string, message: string, details: object): never {
-  throw new TypertRemoteFailure({ code, message, details })
-}
-
 async function durablePromptContent(
   ctx: Context,
   content: readonly SessionPromptRequest['content'][number][],
@@ -580,18 +561,6 @@ function referencedImage(
   return undefined
 }
 
-const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
-
-function canonicalClientTimeZone(value: string): string | undefined {
-  if (value.length === 0 || value.trim() !== value
-    || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
-  try {
-    return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone
-  } catch {
-    return undefined
-  }
-}
-
 function routeServed(ctx: Context, provider: string): boolean {
   return ctx.llm.listProviders().some(entry => entry.id === provider)
 }

+ 16 - 20
packages/api/session-controller/src/history.ts

@@ -6,7 +6,7 @@ import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-sessi
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import type {} from '@deepseek-ai/dsh-subagent'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type {
   SessionAddress,
   SessionChunkRun,
@@ -55,15 +55,15 @@ export class SessionHistoryController {
     const sourceLog = source.events
     const sourceCursor = sourceLog.at(-1)?.seq ?? -1
     if (request.throughSeq > sourceCursor) {
-      reject(
-        'bad-request',
+      throw new RemoteError(
+        'gateway/bad-request',
         `session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`,
         {},
       )
     }
     /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
     if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) {
-      reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
+      throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
     }
     const page = paginate(
       sourceLog,
@@ -155,7 +155,7 @@ export class SessionHistoryController {
         }
         if (item.seq < nextSeq) continue
         if (item.seq !== nextSeq) {
-          reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
+          throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {})
         }
         nextSeq++
         yield entryFor(item)
@@ -211,22 +211,22 @@ function projectionBlock(
 
 function validatePageRequest(request: SessionPageRequest): void {
   if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) {
-    reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
+    throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
   }
   if (request.beforeSeq !== undefined
     && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) {
-    reject('bad-request', 'beforeSeq must be a non-negative safe integer', {})
+    throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
   }
   if (request.maxMessages !== undefined
     && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
-    reject('bad-request', 'maxMessages must be a positive safe integer', {})
+    throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
   }
 }
 
 function validateFollowRequest(request: SessionFollowRequest): void {
   if (request.maxMessages !== undefined
     && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
-    reject('bad-request', 'maxMessages must be a positive safe integer', {})
+    throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
   }
 }
 
@@ -241,34 +241,34 @@ function validateAddress(
 ): void {
   if (address.kind === 'session') {
     if (header.origin === 'subagent') {
-      reject('agent-busy', 'subagent Sessions require their durable parent address', {
+      throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', {
         reason: 'use subagent delivery for this child session',
       })
     }
     return
   }
   if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
-    reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', {
+    throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', {
       childSessionId: address.childSessionId,
     })
   }
   const identity = projections?.values.subagent
   if (identity === null) {
-    reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', {
+    throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', {
       parentSessionId: address.parentSessionId,
       childSessionId: address.childSessionId,
       reason: 'corrupt',
     })
   }
   if (identity === undefined || identity.seq < (header.seedLength ?? 0)) {
-    reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', {
+    throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
       parentSessionId: address.parentSessionId,
       childSessionId: address.childSessionId,
       reason: 'unsupported',
     })
   }
   if (identity.mode !== address.mode) {
-    reject('subagent-unauthorized', 'subagent mode does not match the supplied address', {
+    throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
       childSessionId: address.childSessionId,
     })
   }
@@ -276,18 +276,14 @@ function validateAddress(
 
 function rejectNotFound(address: SessionAddress): never {
   if (address.kind === 'session') {
-    reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
+    throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
   }
-  reject('subagent-not-found', 'subagent is unavailable', {
+  throw new RemoteError('subagent/not-found', 'subagent is unavailable', {
     parentSessionId: address.parentSessionId,
     childSessionId: address.childSessionId,
   })
 }
 
-function reject(code: string, message: string, details: object): never {
-  throw new TypertRemoteFailure({ code, message, details })
-}
-
 function paginate(
   events: readonly SessionEvent[],
   beforeSeq: number | undefined,

+ 13 - 17
packages/api/session-controller/src/index.ts

@@ -6,7 +6,7 @@ import { errorChain } from '@deepseek-ai/dsh-llm'
 import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
-import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import {
   ApiSessionAgentController,
   inspectApiSession,
@@ -264,7 +264,7 @@ export class SessionController extends TypertRemoteService {
    * @param request - path after best-effort Session workspace resolution.
    * @param signal - caller lifetime; abort terminates the native command.
    * @returns confirmation after the native opener accepts the path.
-   * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.
+   * @throws RemoteError when the request is invalid, cancelled, or the opener fails.
    */
   @Remote('openWorkspacePath')
   async openWorkspacePath(
@@ -272,27 +272,23 @@ export class SessionController extends TypertRemoteService {
     signal: AbortSignal,
   ): Promise<SessionOpenWorkspacePathValue> {
     if (request.path.length === 0) {
-      throw new TypertRemoteFailure({
-        code: 'bad-request',
-        message: 'session.openWorkspacePath requires a non-empty path',
-        details: {},
-      })
+      throw new RemoteError(
+        'gateway/bad-request',
+        'session.openWorkspacePath requires a non-empty path',
+        {},
+      )
     }
     signal.throwIfAborted()
     try {
       await this.openPath(request.path, signal)
       return { opened: true }
     } catch (error: unknown) {
-      if (signal.aborted) {
-        throw new TypertRemoteFailure({
-          code: 'cancelled', message: 'path open was aborted', details: {},
-        })
-      }
-      throw new TypertRemoteFailure({
-        code: 'internal',
-        message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
-        details: {},
-      })
+      if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
+      throw new RemoteError(
+        'gateway/internal',
+        `path open failed: ${error instanceof Error ? error.message : String(error)}`,
+        {},
+      )
     }
   }
 

+ 9 - 13
packages/api/session-controller/src/list.ts

@@ -8,7 +8,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-session-projection-cache'
 import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { z } from 'zod'
 import {
   SESSION_SEARCH_RESULT_LIMIT,
@@ -226,8 +226,8 @@ export class ApiSessionList {
     signal.throwIfAborted()
     const provider = this.ctx.get('sessionQuery')
     if (provider === undefined) {
-      reject(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
         {},
       )
@@ -317,9 +317,9 @@ export class ApiSessionList {
     } catch (error: unknown) {
       signal.throwIfAborted()
       if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') {
-        reject('cancelled', 'session search was aborted', {})
+        throw new RemoteError('gateway/cancelled', 'session search was aborted', {})
       }
-      reject('internal', `session search failed: ${String(error)}`, {})
+      throw new RemoteError('gateway/internal', `session search failed: ${String(error)}`, {})
     }
   }
 
@@ -351,25 +351,21 @@ export class ApiSessionList {
 function normalizeSearchQuery(query: string): string {
   const normalized = query.trim()
   if (normalized.length === 0) {
-    reject('bad-request', 'session search query must not be empty', {})
+    throw new RemoteError('gateway/bad-request', 'session search query must not be empty', {})
   }
   if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) {
-    reject(
-      'bad-request',
+    throw new RemoteError(
+      'gateway/bad-request',
       `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`,
       {},
     )
   }
   if (normalized.includes('\0')) {
-    reject('bad-request', 'session search query must not contain NUL', {})
+    throw new RemoteError('gateway/bad-request', 'session search query must not contain NUL', {})
   }
   return normalized
 }
 
-function reject(code: string, message: string, details: object): never {
-  throw new TypertRemoteFailure({ code, message, details })
-}
-
 function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
   return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
 }

+ 11 - 22
packages/api/session-controller/src/skill-catalog.ts

@@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
 import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import { isUserInvocable } from '@deepseek-ai/dsh-skill'
 import type { ScopeKey } from '@deepseek-ai/dsh-scope'
-import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import type { SkillListRequest, SkillListValue } from './types.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -30,7 +30,7 @@ export class SessionSkillCatalog extends TypertRemoteService {
    * @param request - Session identity whose cwd and preset select the catalog view.
    * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics.
    * @returns user-invocable skill metadata without loading skill bodies.
-   * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it.
+   * @throws RemoteError when the Session cannot be inspected or no registry can serve it.
    */
   @Remote
   async list(request: SkillListRequest, signal: AbortSignal): Promise<SkillListValue> {
@@ -48,19 +48,16 @@ export class SessionSkillCatalog extends TypertRemoteService {
     } catch (error: unknown) {
       if (error instanceof SessionQueryError
         && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
-        throw failure(
-          'session-not-found',
-          `session "${sessionId}" not found`,
-          { sessionId },
-        )
+        throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId })
       }
-      throw failure(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         `session "${sessionId}" could not be inspected: ${String(error)}`,
+        {},
       )
     }
     if (cwd === undefined) {
-      throw failure('internal', `session "${sessionId}" has no project cwd`)
+      throw new RemoteError('gateway/internal', `session "${sessionId}" has no project cwd`, {})
     }
 
     const live = this.ctx.agents.get(sessionId)
@@ -68,9 +65,10 @@ export class SessionSkillCatalog extends TypertRemoteService {
     const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
     const skillRegistry = scoped ?? this.ctx.get('skills')
     if (skillRegistry === undefined) {
-      throw failure(
-        'internal',
+      throw new RemoteError(
+        'gateway/internal',
         'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill',
+        {},
       )
     }
 
@@ -86,7 +84,7 @@ export class SessionSkillCatalog extends TypertRemoteService {
         })),
       }
     } catch (error: unknown) {
-      throw failure('internal', `skill listing failed: ${String(error)}`)
+      throw new RemoteError('gateway/internal', `skill listing failed: ${String(error)}`, {})
     }
   }
 
@@ -108,13 +106,4 @@ export class SessionSkillCatalog extends TypertRemoteService {
   }
 }
 
-/** Build one stable Remote failure with optional typed details. */
-function failure(
-  code: 'session-not-found' | 'internal',
-  message: string,
-  details: { readonly sessionId: SessionId } | Record<never, never> = {},
-): TypertRemoteFailure {
-  return new TypertRemoteFailure({ code, message, details })
-}
-
 export default SessionSkillCatalog

+ 30 - 46
packages/api/session-controller/src/types.ts

@@ -174,55 +174,39 @@ export const SESSION_SEARCH_RESULT_LIMIT = 20
 /** Maximum search snippet length in Unicode code points. */
 export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
 
-/** Error details returned by Session Remote methods. */
-export interface SessionErrorDetailsMap {
-  'bad-request': Record<never, never>
-  cancelled: Record<never, never>
-  'session-not-found': { readonly sessionId: SessionId }
-  'model-unavailable': { readonly provider: string; readonly model: string }
-  'session-conflict': {
-    readonly sessionId: SessionId
-    readonly requestedCwd: string
-    readonly existingCwd?: string
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    'session/model-unavailable': { readonly provider: string; readonly model: string }
+    'session/conflict': {
+      readonly sessionId: SessionId
+      readonly requestedCwd: string
+      readonly existingCwd?: string
+    }
+    'session/agent-busy': { readonly reason: string }
+    'session/invalid-time-zone': { readonly value: string }
+    'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
+    'agent-preset/conflict': {
+      readonly sessionId: SessionId
+      readonly requestedPreset: string
+      readonly existingPreset?: string
+    }
+    'session/attachment-invalid': { readonly reason: string }
+    'session/queue-item-not-found': { readonly itemId: MessageId }
+    'session/steer-unavailable': { readonly itemId: MessageId }
+    'session/title-invalid': { readonly sessionId: SessionId }
+    'session/fork-unavailable': { readonly sessionId: SessionId }
+    'subagent/not-found': {
+      readonly parentSessionId: SessionId
+      readonly childSessionId: SessionId
+    }
+    'subagent/catalog-diagnostic': {
+      readonly parentSessionId: SessionId
+      readonly childSessionId: SessionId
+      readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
+    }
   }
-  'invalid-time-zone': { readonly value: string }
-  'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
-  'workspace-not-found': { readonly workspaceId: string }
-  'agent-preset-conflict': {
-    readonly sessionId: SessionId
-    readonly requestedPreset: string
-    readonly existingPreset?: string
-  }
-  'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] }
-  'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string }
-  'agent-busy': { readonly reason: string }
-  'attachment-error': { readonly reason: string }
-  'queue-item-not-found': { readonly itemId: MessageId }
-  'steer-unavailable': { readonly itemId: MessageId }
-  'title-invalid': { readonly sessionId: SessionId }
-  'fork-unavailable': { readonly sessionId: SessionId }
-  'subagent-not-found': {
-    readonly parentSessionId: SessionId
-    readonly childSessionId: SessionId
-  }
-  'subagent-catalog-diagnostic': {
-    readonly parentSessionId: SessionId
-    readonly childSessionId: SessionId
-    readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
-  }
-  'subagent-unauthorized': { readonly childSessionId: SessionId }
-  internal: Record<never, never>
 }
 
-/** Session business failure returned without throwing a carrier error. */
-export type SessionError = {
-  [Code in keyof SessionErrorDetailsMap]: {
-    readonly code: Code
-    readonly message: string
-    readonly details: SessionErrorDetailsMap[Code]
-  }
-}[keyof SessionErrorDetailsMap]
-
 /** Session-addressed request for the human-invocable skill catalog. */
 export interface SkillListRequest {
   readonly sessionId: SessionId

+ 6 - 7
packages/api/session-controller/tests/agent.host.spec.ts

@@ -8,7 +8,6 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
-import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
@@ -154,7 +153,7 @@ describe('ApiSession Agent lookup and recovery', () => {
       header: header('observed-without-cwd', null),
     } as SessionObservation
     await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
-      error: { code: 'session-not-found' },
+      error: { code: 'session/not-found' },
     })
   })
 
@@ -170,7 +169,7 @@ describe('ApiSession Agent lookup and recovery', () => {
     if (host === undefined) throw new Error('Agent Context resolver was not registered')
 
     await expect(host.resolve(live.id)).resolves.toBe(live.ctx)
-    await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure)
+    await expect(host.resolve(SessionId('missing'))).rejects.toMatchObject({ code: 'session/not-found' })
   })
 
   it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
@@ -200,7 +199,7 @@ describe('ApiSession Agent lookup and recovery', () => {
       throw new Error('raced child publication')
     })
     await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
-      error: { code: 'agent-busy' },
+      error: { code: 'session/agent-busy' },
     })
   })
 
@@ -211,7 +210,7 @@ describe('ApiSession Agent lookup and recovery', () => {
       inspect: vi.fn(),
     })
     await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
-      error: { code: 'session-not-found' },
+      error: { code: 'session/not-found' },
     })
 
     const failed = await harness()
@@ -222,7 +221,7 @@ describe('ApiSession Agent lookup and recovery', () => {
     })
     vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
     await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
-      error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
+      error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string },
     })
   })
 
@@ -408,7 +407,7 @@ describe('ApiSession create or adoption', () => {
       mount: () => Promise.resolve(),
     } as never)
     await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
-      error: { code: 'agent-busy' },
+      error: { code: 'session/agent-busy' },
     })
 
     const conflict = await harness()

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

@@ -63,12 +63,14 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
     registerGenerationSource: () => () => {},
     start: () => ({ stop: () => {} }),
   }
-  ctx.reflect.provide('connection', connection)
   ctx.reflect.provide('remote', {
     ...remote,
     $stream: <Item>(options: RemoteStreamOptions<Item>) => (
       new RemoteStream(connection, options)
     ),
+    get $host() {
+      return { home: generation?.host.home, isLoopback: connection.isLoopback }
+    },
     $on: (event: string, listener: RemoteListener) => {
       const eventListeners = listeners.get(event) ?? new Set<RemoteListener>()
       eventListeners.add(listener)

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

@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
 import {
   MutableSessionEventSource, type SessionLiveEventEntry,
 } from '../src/client/contract/events.ts'
-import { transportResult } from '../src/client/contract/result.ts'
 
 function entry(seq: number): SessionLiveEventEntry {
   return {
@@ -76,14 +75,4 @@ describe('Client Session contracts', () => {
     expect(iterate).toHaveBeenCalledOnce()
   })
 
-  it('folds Error and non-Error carrier rejections into Client failures', () => {
-    expect(transportResult(new Error('transport unavailable'))).toEqual({
-      ok: false,
-      error: { code: 'internal', message: 'transport unavailable', details: {} },
-    })
-    expect(transportResult(404)).toEqual({
-      ok: false,
-      error: { code: 'internal', message: '404', details: {} },
-    })
-  })
 })

+ 22 - 17
packages/api/session-controller/tests/commands-create-fork.host.spec.ts

@@ -1,9 +1,10 @@
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
-import { PresetMountError } from '@deepseek-ai/dsh-agent-presets'
+import type {} from '@deepseek-ai/dsh-agent-presets'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace'
 import { describe, expect, it, vi } from 'vitest'
 import {
@@ -14,7 +15,7 @@ import { SessionCommandController } from '../src/commands.ts'
 import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
-  await expect(operation).rejects.toMatchObject({ failure: { code } })
+  await expect(operation).rejects.toMatchObject({ code })
 }
 
 function controllerAgents(overrides: object = {}): ApiSessionAgentController {
@@ -76,7 +77,7 @@ describe('Session creation failures', () => {
     )
     await expectFailure(missingController.create({
       workspaceId: 'missing' as WorkspaceId,
-    }), 'workspace-not-found')
+    }), 'workspace/not-found')
     await missing.fiber.dispose()
 
     const failed = await baseContext()
@@ -97,26 +98,30 @@ describe('Session creation failures', () => {
     await expectFailure(failedController.create({
       sessionId: SessionId('workspace-session'),
       workspaceId: workspace.id,
-    }), 'workspace-attach-failed')
+    }), 'session/workspace-attach-failed')
     await failed.fiber.dispose()
   })
 
   it.each([
     {
-      error: new PresetMountError('broken', 'invalid composition'),
-      code: 'agent-preset-invalid',
+      error: new RemoteError(
+        'agent-preset/invalid',
+        'agent-presets: preset "broken" failed to mount: invalid composition',
+        { agentPreset: 'broken', reason: 'invalid composition' },
+      ),
+      code: 'agent-preset/invalid',
     },
     {
       error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined),
-      code: 'session-conflict',
+      code: 'session/conflict',
     },
     {
       error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'),
-      code: 'session-conflict',
+      code: 'session/conflict',
     },
     {
       error: new Error('factory unavailable'),
-      code: 'internal',
+      code: 'gateway/internal',
     },
   ])('maps $code creation failures', async ({ error, code }) => {
     const ctx = await baseContext()
@@ -140,7 +145,7 @@ describe('Session creation failures', () => {
     await expectFailure(controller.create({
       workspaceId: 'workspace-1' as WorkspaceId,
       cwd: '/workspace',
-    }), 'bad-request')
+    }), 'gateway/bad-request')
     await ctx.fiber.dispose()
   })
 
@@ -179,7 +184,7 @@ describe('Session fork failures', () => {
     )
     await expectFailure(unavailableController.fork({
       sessionId: SessionId('missing'),
-    }), 'session-not-found')
+    }), 'session/not-found')
     await withoutPersistence.fiber.dispose()
 
     const missing = await baseContext()
@@ -191,7 +196,7 @@ describe('Session fork failures', () => {
     const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
     await expectFailure(missingController.fork({
       sessionId: SessionId('missing'),
-    }), 'session-not-found')
+    }), 'session/not-found')
     await missing.fiber.dispose()
   })
 
@@ -201,7 +206,7 @@ describe('Session fork failures', () => {
     vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
     const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
 
-    await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal')
+    await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal')
     await ctx.fiber.dispose()
   })
 
@@ -211,7 +216,7 @@ describe('Session fork failures', () => {
     const source = ctx.sessions.create(SessionId('empty-source'))
     const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
 
-    await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable')
+    await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable')
     await ctx.fiber.dispose()
   })
 
@@ -225,7 +230,7 @@ describe('Session fork failures', () => {
       origin: 'subagent',
     })
     const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default')
-    await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal')
+    await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal')
     await lineage.fiber.dispose()
 
     const creation = await baseContext()
@@ -233,7 +238,7 @@ describe('Session fork failures', () => {
     const source = completedSession(creation, 'creation-source', '/workspace')
     vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed'))
     const creationController = new SessionCommandController(creation, controllerAgents(), '/default')
-    await expectFailure(creationController.fork({ sessionId: source.id }), 'internal')
+    await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal')
     await creation.fiber.dispose()
   })
 
@@ -251,7 +256,7 @@ describe('Session fork failures', () => {
     )
     const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
 
-    await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed')
+    await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed')
     const options = create.mock.calls[0]?.[0]
     if (options === undefined) throw new Error('Agent creation was not attempted')
     expect(options.meta).not.toHaveProperty('cwd')

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

@@ -56,7 +56,7 @@ async function commandHarness(): Promise<{
 }
 
 async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
-  await expect(operation).rejects.toMatchObject({ failure: { code } })
+  await expect(operation).rejects.toMatchObject({ code })
 }
 
 describe('Session queue commands', () => {
@@ -79,21 +79,21 @@ describe('Session queue commands', () => {
           },
         }],
       },
-    })), 'attachment-error')
+    })), 'session/attachment-invalid')
     await expectFailure(Promise.resolve().then(() => controller.updateQueue({
       sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
-    })), 'queue-item-not-found')
+    })), 'session/queue-item-not-found')
     await expectFailure(Promise.resolve().then(() => controller.updateQueue({
       sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
-    })), 'queue-item-not-found')
+    })), 'session/queue-item-not-found')
     await expectFailure(Promise.resolve().then(() => controller.updateQueue({
       sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
-    })), 'steer-unavailable')
+    })), 'session/steer-unavailable')
 
     Object.assign(agent, { status: 'idle' })
     await expectFailure(Promise.resolve().then(() => controller.updateQueue({
       sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
-    })), 'steer-unavailable')
+    })), 'session/steer-unavailable')
     expect(controller.updateQueue({
       sessionId: agent.id,
       itemId: queued.id,
@@ -114,7 +114,7 @@ describe('Session queue commands', () => {
 
     await expectFailure(Promise.resolve().then(() => controller.cancel({
       sessionId: SessionId('missing'),
-    })), 'session-not-found')
+    })), 'session/not-found')
     expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
     expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
     await ctx.fiber.dispose()
@@ -209,7 +209,7 @@ describe('Session attachment authorization', () => {
     )
     await expectFailure(noPersistenceController.attachment({
       sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
-    }), 'session-not-found')
+    }), 'session/not-found')
 
     const missing = new Context()
     await missing.plugin(SessionStore)
@@ -225,7 +225,7 @@ describe('Session attachment authorization', () => {
     )
     await expectFailure(missingController.attachment({
       sessionId: SessionId('missing'), attachmentId: 'att' as never,
-    }), 'session-not-found')
+    }), 'session/not-found')
 
     for (const thrown of [
       new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
@@ -239,7 +239,7 @@ describe('Session attachment authorization', () => {
       await expectFailure(fixture.controller.attachment({
         sessionId: fixture.sessionId,
         attachmentId: ref.attachmentId,
-      }), thrown instanceof AttachmentError ? 'attachment-error' : 'internal')
+      }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal')
       await fixture.ctx.fiber.dispose()
     }
   })
@@ -257,7 +257,7 @@ describe('Session attachment authorization', () => {
 
     await expectFailure(controller.attachment({
       sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
-    }), 'internal')
+    }), 'gateway/internal')
     await ctx.fiber.dispose()
   })
 })

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

@@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+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'
@@ -124,7 +125,7 @@ describe('SessionController facade', () => {
       if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
       else if (outcome === 'domain-error') {
         resolve.mockResolvedValue({
-          error: { code: 'internal', message: 'activation unavailable', details: {} },
+          error: new RemoteError('gateway/internal', 'activation unavailable', {}),
         })
       } else {
         resolve.mockRejectedValue(new Error('activation crashed'))

+ 45 - 68
packages/api/session-controller/tests/fake-api.client.ts

@@ -3,7 +3,7 @@
 // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
 import type {
   MessageId,
-  RpcError, RpcResponse, SessionId, SessionSearchItem,
+  SessionId, SessionSearchItem,
   SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
   WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-api-remotes/client'
@@ -24,10 +24,8 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro
 import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import {
   RemoteStream,
-  RemoteStreamError,
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
-import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
 import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
 
@@ -72,28 +70,21 @@ export function deferred<T>(): Deferred<T> {
   return { promise, resolve, reject }
 }
 
-let nextRpc = 0
-
-export function ok<T>(value: T): RpcResponse<T> {
-  return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
-}
-
-export function err<T>(error: RpcError): RpcResponse<T> {
-  return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
-}
-
-/** Successful generated Remote result for programmable domain fakes. */
-export function remoteOk<T>(value: T): RemoteResult<T> {
+/**
+ * Successful generated Remote result for programmable domain fakes.
+ * @param value - the value the Host answers with.
+ * @returns the success branch of a Remote result.
+ */
+export function ok<T>(value: T): RemoteResult<T> {
   return { ok: true, value }
 }
 
 /**
- * Failed generated Remote result carrying an owner's own failure vocabulary,
- * which the carrier's closed RPC code set does not contain.
+ * Failed generated Remote result carrying the owner's declared failure.
  * @param error - the owner-declared failure.
  * @returns the failure branch of a Remote result.
  */
-export function remoteErr<T>(error: RemoteFailure): RemoteResult<T> {
+export function err<T>(error: RemoteFailure): RemoteResult<T> {
   return { ok: false, error }
 }
 
@@ -129,11 +120,11 @@ export class FakeApiClient {
   readonly followStarts: SessionId[] = []
 
   // Programmable slots (defaults answer OK-empty); reassign per case.
-  onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
-  onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
+  onList: (payload: unknown) => Promise<RemoteResult<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
+  onSearch: (payload: unknown) => Promise<RemoteResult<{ items: SessionSearchItem[]; hasMore: boolean }>> =
     () => Promise.resolve(ok({ items: [], hasMore: false }))
-  onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
-  onSelectModel: (payload: SessionSelectModelRequest) => Promise<RpcResponse<SessionSelectModelValue>> =
+  onCreate: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
+  onSelectModel: (payload: SessionSelectModelRequest) => Promise<RemoteResult<SessionSelectModelValue>> =
     payload => Promise.resolve(ok({
       selected: {
         provider: payload.provider,
@@ -143,19 +134,19 @@ export class FakeApiClient {
           : { reasoningEffort: payload.reasoningEffort }),
       },
     }))
-  onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
-  onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
+  onRename: (payload: unknown) => Promise<RemoteResult<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
+  onFork: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
   onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
-  => Promise<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
+  => Promise<RemoteResult<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
     () => Promise.resolve(ok({ records: [], hasMore: false }))
 
-  onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
+  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==' }))
-  onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
+  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 }>> =
-    () => Promise.resolve(remoteOk({ opened: true as const }))
+    () => Promise.resolve(ok({ opened: true as const }))
 
   private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
   private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
@@ -174,30 +165,30 @@ export class FakeApiClient {
   lastSearchSignal: AbortSignal | undefined
 
   onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
-    = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
+    = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
   onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
-    = () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId }))
+    = () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId }))
 
   onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
-    = () => Promise.resolve(remoteOk({ accepted: true as const }))
+    = () => Promise.resolve(ok({ accepted: true as const }))
 
   onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
-    () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
+    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
 
   onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
-    () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
+    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
 
   onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
-    () => Promise.resolve(remoteOk({ deleted: true }))
+    () => Promise.resolve(ok({ deleted: true }))
 
   onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
-    () => Promise.resolve(remoteOk({ workspaceIds: [] }))
+    () => Promise.resolve(ok({ workspaceIds: [] }))
 
   onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
-    () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
+    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
 
   onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
-    payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
+    payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
 
   /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
   sessionRemotes(): RuntimeRemotes {
@@ -209,8 +200,8 @@ export class FakeApiClient {
         execute: () => Promise.resolve({ ok: true, value: undefined }),
       },
       session: {
-        canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)),
-        list: payload => this.remoteResult('session.list', payload, this.onList(payload)),
+        canOpenWorkspacePath: () => Promise.resolve(ok(true)),
+        list: payload => this.record('session.list', payload, this.onList(payload)),
         modelCatalog: () => Promise.resolve({
           ok: true,
           value: {
@@ -222,20 +213,20 @@ export class FakeApiClient {
         }),
         search: (payload, signal) => {
           this.lastSearchSignal = signal
-          return this.remoteResult('session.search', payload, this.onSearch(payload))
+          return this.record('session.search', payload, this.onSearch(payload))
         },
-        create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)),
-        selectModel: payload => this.remoteResult(
+        create: payload => this.record('session.create', payload, this.onCreate(payload)),
+        selectModel: payload => this.record(
           'session.selectModel',
           payload,
           this.onSelectModel(payload),
         ),
-        rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)),
-        fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)),
-        prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)),
-        attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)),
-        updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)),
-        cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)),
+        rename: payload => this.record('session.rename', payload, this.onRename(payload)),
+        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)),
+        updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
+        cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),
         openWorkspacePath: payload => this.record(
           'session.openWorkspacePath',
           payload,
@@ -333,21 +324,13 @@ export class FakeApiClient {
     return response
   }
 
-  private async remoteResult<T>(
-    method: string,
-    payload: unknown,
-    response: Promise<RpcResponse<T>>,
-  ): Promise<RemoteResult<T>> {
-    return (await this.record(method, payload, response)).result
-  }
-
   private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
     return this.fetchPage(request)
   }
 
   private async fetchPage(
     request: SessionPageRequest,
-    response?: Promise<RpcResponse<SessionPage>>,
+    response?: Promise<RemoteResult<SessionPage>>,
   ): Promise<RemoteResult<SessionPage>> {
     const sessionId = addressSessionId(request.address)
     const payload = request.address.kind === 'session'
@@ -366,7 +349,7 @@ export class FakeApiClient {
         ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
       }
     const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history'
-    const result = await this.remoteResult(method, payload, response ?? this.onHistory({
+    const result = await this.record(method, payload, response ?? this.onHistory({
       sessionId,
       throughSeq: request.throughSeq,
       ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
@@ -398,14 +381,8 @@ export class FakeApiClient {
         sessionId,
         maxMessages: request.maxMessages ?? 50,
       })
-      if (!response.result.ok) {
-        throw new RemoteStreamError(
-          response.result.error.code,
-          response.result.error.message,
-          response.result.error.details,
-        )
-      }
-      const page = response.result.value
+      if (!response.ok) throw response.error
+      const page = response.value
       const tail = page.records.at(-1)
       const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail))
       yield {

+ 39 - 50
packages/api/session-controller/tests/manager.client.spec.ts

@@ -5,10 +5,11 @@
 
 import { describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import type {} from '@deepseek-ai/dsh-session-title/client'
 import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts'
+import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
 import { entries, plainTurn } from './event-script.client.ts'
 
 const S1 = 'fk-m1' as SessionId
@@ -92,10 +93,10 @@ describe('list lifecycle', () => {
 
   it('keeps the error in the list snapshot on failure', async () => {
     const api = new FakeApiClient()
-    api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
+    api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'boom', {})))
     const manager = new SessionManager(fakeRemote(api))
     await manager.refreshList()
-    expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
+    expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } })
     // A failed pull does not step the arrival phase: still pending.
     expect(manager.getListSnapshot().phase).toBe('pending')
   })
@@ -108,7 +109,7 @@ describe('list lifecycle', () => {
     expect(manager.getListSnapshot().phase).toBe('ready')
     // Sticky across later failures: the pull-activity axis reports the error,
     // the arrival phase holds.
-    api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
+    api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'down', {})))
     await manager.refreshList()
     expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
     // And across an empty re-pull (empty-with-ready = truly no sessions).
@@ -227,25 +228,18 @@ describe('search', () => {
     expect(api.lastSearchSignal).toBe(signal)
   })
 
-  it('preserves business errors and folds transport failures', async () => {
+  it('preserves business errors and propagates a non-Remote throw', async () => {
     const api = new FakeApiClient()
     const manager = new SessionManager(fakeRemote(api))
-    api.onSearch = () => Promise.resolve(err({
-      code: 'internal',
-      message: 'index unavailable',
-      details: {},
-    }))
+    api.onSearch = () => Promise.resolve(err(new RemoteError('gateway/internal', 'index unavailable', {})))
     const signal = new AbortController().signal
     await expect(manager.search('first', signal)).resolves.toMatchObject({
       ok: false,
-      error: { code: 'internal', message: 'index unavailable' },
+      error: { code: 'gateway/internal', message: 'index unavailable' },
     })
 
     api.onSearch = () => Promise.reject(new Error('wire down'))
-    await expect(manager.search('second', signal)).resolves.toMatchObject({
-      ok: false,
-      error: { code: 'internal', message: 'wire down' },
-    })
+    await expect(manager.search('second', signal)).rejects.toThrow('wire down')
   })
 })
 
@@ -279,7 +273,7 @@ describe('subagent catalogs', () => {
       summary(S1),
       summary(S2, { parentSessionId: S1, origin: 'subagent' }),
     ] as never[] }))
-    api.onSubagentList = () => Promise.resolve(remoteOk({
+    api.onSubagentList = () => Promise.resolve(ok({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'running', hasChildren: false,
@@ -375,7 +369,7 @@ describe('subagent catalogs', () => {
   it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
     const api = new FakeApiClient()
     const root = 'fk-root' as SessionId
-    api.onSubagentList = () => Promise.resolve(remoteOk({
+    api.onSubagentList = () => Promise.resolve(ok({
       entries: [
         {
           kind: 'child', id: S1, mode: 'continuable', label: 'parent',
@@ -413,7 +407,7 @@ describe('subagent catalogs', () => {
     manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
       parentSessionId: S1, origin: 'subagent',
     }))
-    response.resolve(remoteOk({
+    response.resolve(ok({
       entries: [{
         kind: 'child', id: S1, mode: 'continuable', label: 'parent',
         activity: 'inactive', hasChildren: false,
@@ -426,7 +420,7 @@ describe('subagent catalogs', () => {
       { kind: 'child', id: S1, hasChildren: true },
     ])
 
-    api.onSubagentList = () => Promise.resolve(remoteOk({
+    api.onSubagentList = () => Promise.resolve(ok({
       entries: [{
         kind: 'child', id: S1, mode: 'continuable', label: 'parent',
         activity: 'inactive', hasChildren: false,
@@ -449,7 +443,7 @@ describe('subagent catalogs', () => {
 
     manager.handleSessionStatus(S1, false)
     manager.handleSessionStatus(S2, true)
-    response.resolve(remoteOk({
+    response.resolve(ok({
       entries: [
         {
           kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
@@ -472,7 +466,7 @@ describe('subagent catalogs', () => {
 
   it('marks a detached catalog child inactive without requiring a selected address', async () => {
     const api = new FakeApiClient()
-    api.onSubagentList = () => Promise.resolve(remoteOk({
+    api.onSubagentList = () => Promise.resolve(ok({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'running', hasChildren: false,
@@ -498,8 +492,8 @@ describe('subagent catalogs', () => {
 
     const refresh = manager.refreshSubagents(root)
     expect(manager.refreshSubagents(root)).toBe(refresh)
-    api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
-    first.resolve(remoteOk({ entries: [], parentAvailable: true }))
+    api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
+    first.resolve(ok({ entries: [], parentAvailable: true }))
     await refresh
 
     expect(api.callsOf('subagents.list')).toHaveLength(1)
@@ -524,7 +518,7 @@ describe('subagent catalogs', () => {
       manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
       await vi.advanceTimersByTimeAsync(50)
       api.onSubagentList = () => second.promise
-      first.resolve(remoteOk({
+      first.resolve(ok({
         entries: [{
           kind: 'child', id: S1, mode: 'continuable', label: 'older',
           activity: 'inactive', hasChildren: false,
@@ -533,7 +527,7 @@ describe('subagent catalogs', () => {
       }))
       await refresh
       // The trailing pull is already in flight (kicked synchronously in finally).
-      second.resolve(remoteOk({
+      second.resolve(ok({
         entries: [
           {
             kind: 'child', id: S1, mode: 'continuable', label: 'older',
@@ -571,7 +565,7 @@ describe('subagent catalogs', () => {
     api.onSubagentList = () => first.promise
     const manager = new SessionManager(fakeRemote(api))
     const refresh = manager.refreshSubagents(root)
-    first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
+    first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
     await refresh
     manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
 
@@ -583,12 +577,12 @@ describe('subagent catalogs', () => {
     manager.handleSessionRemoved(root)
     const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
     api.onSubagentList = () => trailing.promise
-    mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
+    mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
     await midRefresh
     expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
 
-    trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} }))
+    trailing.resolve(err(new RemoteError('gateway/internal', 'trailing pull failed', {})))
     await vi.waitFor(() => {
       expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
         state: 'error',
@@ -605,7 +599,7 @@ describe('subagent catalogs', () => {
   it('invalidates catalog availability when the owning parent is removed', async () => {
     const api = new FakeApiClient()
     const root = 'fk-root' as SessionId
-    api.onSubagentList = () => Promise.resolve(remoteOk({
+    api.onSubagentList = () => Promise.resolve(ok({
       entries: [{
         kind: 'child', id: S2, mode: 'continuable', label: 'worker',
         activity: 'inactive', hasChildren: false,
@@ -625,12 +619,11 @@ describe('subagent catalogs', () => {
 })
 
 describe('remaining branches', () => {
-  it('refreshList folds a transport throw into the error state', async () => {
+  it('refreshList propagates a non-Remote throw', async () => {
     const api = new FakeApiClient()
     api.onList = () => Promise.reject(new Error('list wire down'))
     const manager = new SessionManager(fakeRemote(api))
-    await manager.refreshList()
-    expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
+    await expect(manager.refreshList()).rejects.toThrow('list wire down')
   })
 
   it('refreshList pushes running bits down to already-instantiated sessions', async () => {
@@ -652,36 +645,32 @@ describe('remaining branches', () => {
     await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
     expect(manager.getListSnapshot().items).toHaveLength(1)
     api.onCreate = () => Promise.reject(new Error('create wire down'))
-    expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
+    await expect(manager.create()).rejects.toThrow('create wire down')
     // Business error passes through untouched.
-    api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
+    api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', 'no', {})))
     expect(await manager.create()).toMatchObject({ ok: false })
   })
 
   it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
     const api = new FakeApiClient()
-    api.onCreate = () => Promise.resolve(err({
-      code: 'workspace-attach-failed',
-      message: 'published but unattached',
-      details: { sessionId: S1, workspaceId: 'w1' },
-    } as never))
+    api.onCreate = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', {
+      sessionId: S1, workspaceId: 'w1',
+    })))
     const manager = new SessionManager(fakeRemote(api))
     const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
-    expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
+    expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
     expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
     expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
   })
 
   it('reconciles a fork child published before workspace attachment fails', async () => {
     const api = new FakeApiClient()
-    api.onFork = () => Promise.resolve(err({
-      code: 'workspace-attach-failed',
-      message: 'forked but unattached',
-      details: { sessionId: S2, workspaceId: 'w1' },
-    } as never))
+    api.onFork = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
+      sessionId: S2, workspaceId: 'w1',
+    })))
     const manager = new SessionManager(fakeRemote(api))
     const result = await manager.fork({ sessionId: S1 })
-    expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
+    expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
     expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
       sessionId: S2,
       parentSessionId: S1,
@@ -693,8 +682,8 @@ describe('remaining branches', () => {
     const api = new FakeApiClient()
     api.onCreate = () => Promise.reject(new Error('response lost'))
     const manager = new SessionManager(fakeRemote(api))
-    const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
-    expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
+    await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 }))
+      .rejects.toThrow('response lost')
     expect(manager.getListSnapshot().items).toEqual([])
 
     manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
@@ -790,8 +779,8 @@ describe('connected generation', () => {
 
     manager.handleConnected()
     expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
-    parent.resolve(remoteOk({ entries: [], parentAvailable: true }))
-    child.resolve(remoteOk({ entries: [], parentAvailable: true }))
+    parent.resolve(ok({ entries: [], parentAvailable: true }))
+    child.resolve(ok({ entries: [], parentAvailable: true }))
 
     await vi.waitFor(() => {
       expect(api.callsOf('session.list')).toHaveLength(1)

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

@@ -13,7 +13,6 @@ import SessionStore from '@deepseek-ai/dsh-session'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
-import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
@@ -493,17 +492,13 @@ describe('Remote Agent and Session lookup policy', () => {
     const sessionLookup = ctx.typert.lookups.get('session')
     if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
     const ownershipFailure = {
-      failure: {
-        code: 'agent-busy',
-        details: { reason: 'use subagent delivery for this child session' },
-      },
+      code: 'session/agent-busy',
+      details: { reason: 'use subagent delivery for this child session' },
     }
 
     const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
     const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
-    await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure)
     await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
-    await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure)
     await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
     expect(resume).not.toHaveBeenCalled()
     expect(inspect).toHaveBeenCalledOnce()
@@ -576,14 +571,14 @@ describe('subagent ownership fence', () => {
     expect(prompt.ok).toBe(false)
     if (!prompt.ok) {
       expect(prompt.error).toMatchObject({
-        code: 'agent-busy',
+        code: 'session/agent-busy',
         details: { reason: 'use subagent delivery for this child session' },
       })
     }
 
     const create = await remote.create(request({ sessionId, cwd: '/proj' }))
     expect(create.ok).toBe(false)
-    if (!create.ok) expect(create.error.code).toBe('agent-busy')
+    if (!create.ok) expect(create.error.code).toBe('session/agent-busy')
     expect(resume).not.toHaveBeenCalled()
     expect(ctx.agents.get(sessionId)).toBeUndefined()
     expect(inspect).toHaveBeenCalledTimes(3)
@@ -626,7 +621,7 @@ describe('subagent ownership fence', () => {
     }))
     expect(resume).toHaveBeenCalledTimes(1)
     expect(prompt.ok).toBe(false)
-    if (!prompt.ok) expect(prompt.error.code).toBe('internal')
+    if (!prompt.ok) expect(prompt.error.code).toBe('gateway/internal')
   })
 
   it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
@@ -661,7 +656,7 @@ describe('subagent ownership fence', () => {
 
     const stopped = await remote.cancel(request({ sessionId: originChild.id }))
     expect(stopped.ok).toBe(false)
-    if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy')
+    if (!stopped.ok) expect(stopped.error.code).toBe('session/agent-busy')
     expect(cancel).not.toHaveBeenCalled()
 
     const queued = await remote.updateQueue(request({
@@ -670,7 +665,7 @@ describe('subagent ownership fence', () => {
       action: { kind: 'remove' },
     }))
     expect(queued.ok).toBe(false)
-    if (!queued.ok) expect(queued.error.code).toBe('agent-busy')
+    if (!queued.ok) expect(queued.error.code).toBe('session/agent-busy')
     expect(updateInbox).not.toHaveBeenCalled()
 
     const selection = await remote.selectModel(request({
@@ -679,11 +674,11 @@ describe('subagent ownership fence', () => {
       model: 'm',
     }))
     expect(selection.ok).toBe(false)
-    if (!selection.ok) expect(selection.error.code).toBe('agent-busy')
+    if (!selection.ok) expect(selection.error.code).toBe('session/agent-busy')
 
     const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
     expect(create.ok).toBe(false)
-    if (!create.ok) expect(create.error.code).toBe('agent-busy')
+    if (!create.ok) expect(create.error.code).toBe('session/agent-busy')
 
     expect(ctx.agents.get(originChild.id)).toBe(originChild)
   })
@@ -770,10 +765,10 @@ describe('subagent ownership fence', () => {
         content: [{ type: 'text' as const, text: 'invalid zone' }],
         clientTimeZone,
       }))
-      expect(invalid).toEqual({
+      expect(invalid).toMatchObject({
         ok: false,
         error: {
-          code: 'invalid-time-zone',
+          code: 'session/invalid-time-zone',
           message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
           details: { value: clientTimeZone },
         },
@@ -801,7 +796,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
     })
     expect(response.ok).toBe(false)
     if (!response.ok) {
-      expect(response.error.code).toBe('session-not-found')
+      expect(response.error.code).toBe('session/not-found')
     }
   })
 
@@ -821,7 +816,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
       throughSeq: -1,
     })
     expect(response.ok).toBe(false)
-    if (!response.ok) expect(response.error.code).toBe('session-not-found')
+    if (!response.ok) expect(response.error.code).toBe('session/not-found')
     expect(inspect).toHaveBeenCalledOnce()
   })
 })
@@ -850,7 +845,7 @@ describe('sessions.prompt synchronous rejection', () => {
       }))
       expect(response.ok).toBe(false)
       if (!response.ok) {
-        expect(response.error.code).toBe('agent-busy')
+        expect(response.error.code).toBe('session/agent-busy')
         expect(response.error.message).toBe('prompt rejected')
         expect(response.error.details).toEqual({
           reason: 'Error: agent "session-throwing" lifecycle disposed',
@@ -891,7 +886,7 @@ describe('sessions.prompt synchronous rejection', () => {
     expect(selection.ok).toBe(false)
     if (!selection.ok) {
       expect(selection.error).toMatchObject({
-        code: 'agent-busy',
+        code: 'session/agent-busy',
         details: { reason: 'use subagent delivery for this child session' },
       })
     }

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

@@ -216,7 +216,7 @@ describe('sessions.fork', () => {
 
     for (const atSeq of [-1, 0.5]) {
       await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq })))
-        .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
+        .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
     }
     expect(ctx.sessions.list()).toEqual([])
     await ctx.fiber.dispose()
@@ -246,7 +246,7 @@ describe('sessions.fork', () => {
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'fork-unavailable', details: { sessionId: source.id } },
+      error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
     })
     if (!response.ok) expect(response.error.message).toMatch(/has not completed/)
     await ctx.fiber.dispose()

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

@@ -22,7 +22,7 @@ import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
 import { ApiSessionAgentController } from '../src/agent.ts'
 import { buildModelCatalog } from '../src/catalog.ts'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { createSessionTestRemote } from './test-remote.ts'
 
 function request<P>(payload: P): P {
@@ -110,11 +110,7 @@ async function harness(logged?: {
     'Remote Rejected',
     [],
     undefined,
-    new TypertRemoteFailure({
-      code: 'fixture-rejected',
-      message: 'fixture rejected the selection',
-      details: { provider: 'remote-rejected' },
-    }),
+    new RemoteError('gateway/internal', 'fixture rejected the selection', {}),
   ))
   ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
   ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
@@ -225,7 +221,7 @@ describe('Web session model selection', () => {
     }))
     expect(denied).toMatchObject({
       ok: false,
-      error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
+      error: { code: 'session/attachment-invalid', details: { reason: 'TOO_MANY_IMAGES' } },
     })
     expect(saveImage).toHaveBeenCalledTimes(2)
     await ctx.fiber.dispose()
@@ -294,7 +290,7 @@ describe('Web session model selection', () => {
     }))
     expect(denied).toMatchObject({
       ok: false,
-      error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
+      error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
     })
     expect(readImage).toHaveBeenCalledOnce()
     await ctx.fiber.dispose()
@@ -423,7 +419,7 @@ describe('Web session model selection', () => {
     expect(unsupported).toMatchObject({
       ok: false,
       error: {
-        code: 'model-unavailable',
+        code: 'session/model-unavailable',
         message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
       },
     })
@@ -433,10 +429,10 @@ describe('Web session model selection', () => {
       provider: 'missing',
       model: 'model',
     }))
-    expect(rejected).toEqual({
+    expect(rejected).toMatchObject({
       ok: false,
       error: {
-        code: 'model-unavailable',
+        code: 'session/model-unavailable',
         message: 'no adapter registered for provider "missing"',
         details: { provider: 'missing', model: 'model' },
       },
@@ -445,12 +441,12 @@ describe('Web session model selection', () => {
       sessionId,
       provider: 'remote-rejected',
       model: 'model',
-    }))).toEqual({
+    }))).toMatchObject({
       ok: false,
       error: {
-        code: 'fixture-rejected',
+        code: 'gateway/internal',
         message: 'fixture rejected the selection',
-        details: { provider: 'remote-rejected' },
+        details: {},
       },
     })
     expect(currentSelection(ctx, sessionId))
@@ -561,7 +557,7 @@ describe('Web session model selection', () => {
     }))
     expect(refused).toMatchObject({
       ok: false,
-      error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
+      error: { code: 'session/model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
     })
     const unavailableCatalog = await buildModelCatalog(ctx)
     expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false)
@@ -621,9 +617,7 @@ describe('Web session model selection', () => {
       saveImages: () => {
         if (saveMode === 'error') return Promise.reject(new Error('image store offline'))
         if (saveMode === 'remote') {
-          return Promise.reject(new TypertRemoteFailure({
-            code: 'fixture-rejected', message: 'fixture rejected', details: {},
-          }))
+          return Promise.reject(new RemoteError('gateway/internal', 'fixture rejected', {}))
         }
         return Promise.resolve([savedRef])
       },
@@ -643,7 +637,7 @@ describe('Web session model selection', () => {
       sessionId, mode: 'queue', content: [image],
     }))).toMatchObject({
       ok: false,
-      error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
+      error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
     })
 
     expectValue(await remote.selectModel(request({
@@ -653,17 +647,17 @@ describe('Web session model selection', () => {
       sessionId, mode: 'queue', content: [{ ...image, data: '' }],
     }))).toMatchObject({
       ok: false,
-      error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } },
+      error: { code: 'session/attachment-invalid', details: { reason: 'INVALID_IMAGE_BASE64' } },
     })
 
     saveMode = 'error'
     expect(await remote.prompt(promptRequest({
       sessionId, mode: 'queue', content: [image],
-    }))).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
+    }))).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
     saveMode = 'remote'
     expect(await remote.prompt(promptRequest({
       sessionId, mode: 'queue', content: [image],
-    }))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } })
+    }))).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'fixture rejected' } })
     saveMode = 'success'
     expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] })))
     expect(followup).toHaveBeenCalledOnce()
@@ -681,13 +675,13 @@ describe('Web session model selection', () => {
     expect(await remote.selectModel(request({
       sessionId, provider: 'metadata-broken', model: 'broken',
     }))).toMatchObject({
-      ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' },
+      ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' },
     })
     expect(await remote.selectModel(request({
       sessionId, provider: 'string-error', model: 'broken',
     }))).toMatchObject({
       ok: false,
-      error: { code: 'model-unavailable', message: 'string selection failure' },
+      error: { code: 'session/model-unavailable', message: 'string selection failure' },
     })
     await ctx.fiber.dispose()
   })

+ 7 - 7
packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts

@@ -88,7 +88,7 @@ describe('session/openWorkspacePath', () => {
     })
 
     await expect(remote.openWorkspacePath({ path: '' }))
-      .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
+      .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
     expect(openPath).not.toHaveBeenCalled()
   })
 
@@ -105,13 +105,13 @@ describe('session/openWorkspacePath', () => {
     await expect(remote.openWorkspacePath({ path: 'result.html' }))
       .resolves.toMatchObject({
         ok: false,
-        error: { code: 'internal', message: 'path open failed: desktop unavailable' },
+        error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' },
       })
 
     const aborted = new AbortController()
-    aborted.abort(new Error('cancelled'))
+    aborted.abort(new Error('gateway/cancelled'))
     await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal))
-      .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } })
+      .resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
   })
 
   it('classifies opener cancellation and non-Error failures', async () => {
@@ -119,7 +119,7 @@ describe('session/openWorkspacePath', () => {
     const aborted = new AbortController()
     const openPath = vi.fn()
       .mockImplementationOnce(async () => {
-        aborted.abort(new Error('cancelled'))
+        aborted.abort(new Error('gateway/cancelled'))
         throw new Error('opening stopped')
       })
       .mockRejectedValueOnce('desktop unavailable')
@@ -130,11 +130,11 @@ describe('session/openWorkspacePath', () => {
     })
 
     await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal))
-      .rejects.toMatchObject({ failure: { code: 'cancelled' } })
+      .rejects.toMatchObject({ code: 'gateway/cancelled' })
     await expect(controller.openWorkspacePath({
       path: 'second.html',
     }, new AbortController().signal)).rejects.toMatchObject({
-      failure: { code: 'internal', message: 'path open failed: desktop unavailable' },
+      code: 'gateway/internal', message: 'path open failed: desktop unavailable',
     })
   })
 })

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

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Session } from '../src/client/sessions/session.ts'
 import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
 import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
@@ -98,7 +99,7 @@ describe('beginSubmission', () => {
 describe('prompt-coupled retirement', () => {
   it('a rejected identified prompt retires its echo immediately alongside promptError', async () => {
     const { api, session } = makeSession()
-    api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
+    api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
     const retirements: PendingSubmissionRetirement[] = []
     const handle = session.beginSubmission({
       text: '失败的',
@@ -121,7 +122,7 @@ describe('prompt-coupled retirement', () => {
 
   it('an unidentified prompt failure leaves registered echoes alone', async () => {
     const { api, session } = makeSession()
-    api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
+    api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
     session.beginSubmission({ text: '还在', images: [] })
     await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)

+ 12 - 5
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -6,9 +6,10 @@ import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
-import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
+import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { describe, expect, it } from 'vitest'
 import { createSessionTestRemote } from './test-remote.ts'
 
@@ -26,7 +27,13 @@ function roster(ids: readonly string[]): unknown {
     defaultId: ids[0],
     resolve: (id?: string) => {
       const wanted = id ?? ids[0] ?? ''
-      if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
+      if (!ids.includes(wanted)) {
+        return Promise.reject(new RemoteError(
+          'agent-preset/not-found',
+          `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`,
+          { agentPreset: wanted, available: ids },
+        ))
+      }
       return Promise.resolve(presetOf(wanted))
     },
     mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
@@ -91,7 +98,7 @@ describe('session.create Agent preset identity', () => {
 
     const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' })
 
-    expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } })
+    expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
   })
 
   it('refuses to adopt a live Session under a different preset', async () => {
@@ -103,7 +110,7 @@ describe('session.create Agent preset identity', () => {
     expect(response).toMatchObject({
       ok: false,
       error: {
-        code: 'agent-preset-conflict',
+        code: 'agent-preset/conflict',
         details: {
           sessionId: 's4',
           requestedPreset: 'standard',
@@ -153,7 +160,7 @@ describe('session.create Agent preset identity', () => {
     expect(response).toMatchObject({
       ok: false,
       error: {
-        code: 'agent-preset-conflict',
+        code: 'agent-preset/conflict',
         details: {
           sessionId: 's7',
           requestedPreset: 'standard',

+ 3 - 3
packages/api/session-controller/tests/session-rename.host.spec.ts

@@ -90,7 +90,7 @@ describe('sessions.rename', () => {
     expect(response.ok).toBe(false)
     if (!response.ok) {
       expect(response.error).toMatchObject({
-        code: 'title-invalid',
+        code: 'session/title-invalid',
         details: { sessionId: source.id },
       })
       // The message renders verbatim in the rename dialog's alert.
@@ -109,7 +109,7 @@ describe('sessions.rename', () => {
 
     const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' }))
     expect(response.ok).toBe(false)
-    if (!response.ok) expect(response.error.code).toBe('internal')
+    if (!response.ok) expect(response.error.code).toBe('gateway/internal')
   })
 
   it('answers internal when the composition mounts no session-title service', async () => {
@@ -119,7 +119,7 @@ describe('sessions.rename', () => {
     const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' }))
     expect(response.ok).toBe(false)
     if (!response.ok) {
-      expect(response.error.code).toBe('internal')
+      expect(response.error.code).toBe('gateway/internal')
       expect(response.error.message).toMatch(/mounts no session-title service/)
     }
   })

+ 18 - 18
packages/api/session-controller/tests/session-search.host.spec.ts

@@ -98,7 +98,7 @@ describe('session.search', () => {
     const list = new ApiSessionList(ctx, 0)
 
     await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({
-      failure: { code: 'internal' },
+      code: 'gateway/internal',
     })
     await ctx.fiber.dispose()
   })
@@ -191,7 +191,7 @@ describe('session.search', () => {
 
     for (const query of ['', '   ', 'contains\0nul', 'x'.repeat(501)]) {
       await expect(remote.search(request(query), new AbortController().signal))
-        .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
+        .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
     }
     expect(searchSessions).not.toHaveBeenCalled()
     await ctx.fiber.dispose()
@@ -353,7 +353,7 @@ describe('session.search', () => {
 
     expect(response.ok).toBe(false)
     if (response.ok) throw new Error('unreachable')
-    expect(response.error).toMatchObject({ code: 'internal' })
+    expect(response.error).toMatchObject({ code: 'gateway/internal' })
     expect(response.error.message).toContain('100-call work budget')
     expect(searchSessions).toHaveBeenCalledTimes(100)
   })
@@ -457,7 +457,7 @@ describe('session.search', () => {
 
     expect(response.ok).toBe(false)
     if (response.ok) throw new Error('unreachable')
-    expect(response.error.code).toBe('internal')
+    expect(response.error.code).toBe('gateway/internal')
     expect(response.error.message).toContain('100-call work budget')
     expect(response).not.toHaveProperty('value')
     expect(searchSessions).toHaveBeenCalledTimes(100)
@@ -486,7 +486,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'cancelled' },
+      error: { code: 'gateway/cancelled' },
     })
     expect(searchSessions).toHaveBeenCalledTimes(2)
   })
@@ -507,7 +507,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'internal' },
+      error: { code: 'gateway/internal' },
     })
     expect(response).not.toHaveProperty('value')
     expect(searchSessions).toHaveBeenCalledOnce()
@@ -531,7 +531,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'internal' },
+      error: { code: 'gateway/internal' },
     })
     expect(searchSessions).toHaveBeenCalledTimes(2)
     expect(searchSessions.mock.calls.map(([providerRequest]) => (
@@ -558,7 +558,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'internal' },
+      error: { code: 'gateway/internal' },
     })
     expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
       .toEqual([20, 10, 5, 2, 1])
@@ -584,7 +584,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'cancelled' },
+      error: { code: 'gateway/cancelled' },
     })
     expect(searchSessions).toHaveBeenCalledOnce()
   })
@@ -603,7 +603,7 @@ describe('session.search', () => {
 
     expect(response.ok).toBe(false)
     if (response.ok) throw new Error('unreachable')
-    expect(response.error).toMatchObject({ code: 'internal' })
+    expect(response.error).toMatchObject({ code: 'gateway/internal' })
     expect(response.error.message).toContain('returned 21 items; maximum is 20')
   })
 
@@ -629,7 +629,7 @@ describe('session.search', () => {
 
     expect(response.ok).toBe(false)
     if (response.ok) throw new Error('unreachable')
-    expect(response.error).toMatchObject({ code: 'internal' })
+    expect(response.error).toMatchObject({ code: 'gateway/internal' })
     expect(response.error.message).toContain('returned 11 items; maximum is 10')
     expect(searchSessions).toHaveBeenCalledTimes(2)
   })
@@ -677,7 +677,7 @@ describe('session.search', () => {
 
     expect(response.ok).toBe(false)
     if (response.ok) throw new Error('unreachable')
-    expect(response.error).toMatchObject({ code: 'internal' })
+    expect(response.error).toMatchObject({ code: 'gateway/internal' })
     expect(response.error.message).toContain('repeated a continuation cursor')
     expect(searchSessions).toHaveBeenCalledTimes(2)
   })
@@ -700,7 +700,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'internal' },
+      error: { code: 'gateway/internal' },
     })
     expect(response).not.toHaveProperty('value')
     if (response.ok) throw new Error('unreachable')
@@ -755,7 +755,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'cancelled' },
+      error: { code: 'gateway/cancelled' },
     })
     expect(searchSessions).toHaveBeenCalledTimes(2)
     for (const call of searchSessions.mock.calls) {
@@ -821,7 +821,7 @@ describe('session.search', () => {
 
     expect(response).toMatchObject({
       ok: false,
-      error: { code: 'cancelled' },
+      error: { code: 'gateway/cancelled' },
     })
     expect(list).toHaveBeenCalledOnce()
     expect(locateCalls).toBe(0)
@@ -863,7 +863,7 @@ describe('session.search', () => {
     )
     expect(cancelledBeforeLookup).toMatchObject({
       ok: false,
-      error: { code: 'cancelled' },
+      error: { code: 'gateway/cancelled' },
     })
 
     const ctx = await baseContext()
@@ -881,7 +881,7 @@ describe('session.search', () => {
     )
     expect(cancelled).toMatchObject({
       ok: false,
-      error: { code: 'cancelled' },
+      error: { code: 'gateway/cancelled' },
     })
 
     const failed = await remote.search(
@@ -890,7 +890,7 @@ describe('session.search', () => {
     )
     expect(failed.ok).toBe(false)
     if (failed.ok) throw new Error('unreachable')
-    expect(failed.error.code).toBe('internal')
+    expect(failed.error.code).toBe('gateway/internal')
     expect(failed.error.message).toContain('database unavailable')
   })
 })

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

@@ -161,9 +161,9 @@ describe('SessionSkillCatalog', () => {
         'session "missing-skills" not found',
         'SESSION_QUERY_SESSION_NOT_FOUND',
       ),
-      code: 'session-not-found',
+      code: 'session/not-found',
     },
-    { error: new Error('storage offline'), code: 'internal' },
+    { error: new Error('storage offline'), code: 'gateway/internal' },
   ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => {
     const ctx = await context()
     ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never)
@@ -172,7 +172,7 @@ describe('SessionSkillCatalog', () => {
     await expect(catalog.list(
       { sessionId: SessionId('missing-skills') },
       new AbortController().signal,
-    )).rejects.toMatchObject({ failure: { code } })
+    )).rejects.toMatchObject({ code })
   })
 
   it('reports an absent skill registry instead of an empty catalog', async () => {
@@ -184,7 +184,7 @@ describe('SessionSkillCatalog', () => {
     const catalog = new SessionSkillCatalog(ctx)
 
     const failed = catalog.list({ sessionId }, new AbortController().signal)
-    await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(failed).rejects.toMatchObject({ code: 'gateway/internal' })
     await expect(failed).rejects.toThrow('skill registry is absent')
   })
 
@@ -199,10 +199,10 @@ describe('SessionSkillCatalog', () => {
     const catalog = new SessionSkillCatalog(ctx)
 
     const unprojected = catalog.list({ sessionId }, new AbortController().signal)
-    await expect(unprojected).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' })
     await expect(unprojected).rejects.toThrow('projected Session observation')
     const cwdless = catalog.list({ sessionId }, new AbortController().signal)
-    await expect(cwdless).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' })
     await expect(cwdless).rejects.toThrow('has no project cwd')
   })
 
@@ -219,7 +219,7 @@ describe('SessionSkillCatalog', () => {
 
     await expect(catalog.list({ sessionId }, new AbortController().signal))
       .rejects.toMatchObject({
-        failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' },
+        code: 'gateway/internal', message: 'skill listing failed: Error: catalog offline',
       })
   })
 })

+ 72 - 49
packages/api/session-controller/tests/session.client.spec.ts

@@ -1,11 +1,11 @@
 /** Session object lifecycle, event-window transport, commands, and resync behavior. */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
-import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts'
+import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
 import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
 
 const SID = 'fk-s1' as SessionId
@@ -76,21 +76,19 @@ describe('Session open', () => {
     expect(api.callsOf('session.history')).toEqual([])
   })
 
-  it('lands an error result in openState=error with the RpcError kept', async () => {
+  it('lands an error result in openState=error with the Remote failure kept', async () => {
     const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
+    api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID })))
     await session.open()
     const snapshot = session.getSnapshot()
     expect(snapshot.openState).toBe('error')
-    expect(snapshot.openError?.code).toBe('session-not-found')
+    expect(snapshot.openError?.code).toBe('session/not-found')
   })
 
-  it('folds a transport throw into openState=error / internal', async () => {
+  it('propagates a non-Remote throw raised while opening', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => Promise.reject(new Error('socket died'))
-    await session.open()
-    expect(session.getSnapshot().openState).toBe('error')
-    expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' })
+    await expect(session.open()).rejects.toThrow('socket died')
   })
 
   it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
@@ -283,23 +281,24 @@ describe('prompt and cancel errors', () => {
 
   it('lands an interrupt business failure in promptError with op=stop', async () => {
     const api = new FakeApiClient()
-    api.onSubagentInterrupt = () => Promise.resolve(remoteErr({
-      code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
-    }))
+    api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID })))
     const session = new Session(SID, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
       parentAvailable: true,
     })
     await session.open()
     const cancelled = await session.cancel()
-    expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
+    expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } })
     expect(session.getSnapshot().promptError).toMatchObject({
-      op: 'stop', error: { code: 'subagent-unauthorized' },
+      op: 'stop', error: { code: 'subagent/unauthorized' },
     })
   })
 
-  it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
+  it('sends a one-shot address to the Host under the continuable marker', async () => {
     const api = new FakeApiClient()
+    api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(
+      'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID },
+    )))
     const session = new Session(SID, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
     })
@@ -307,8 +306,15 @@ describe('prompt and cancel errors', () => {
     const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
     const cancelled = await session.cancel()
 
-    expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
-    expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
+    // The Host reads the durable descriptor; the wire marker stays 'continuable'.
+    expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } })
+    expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
+    expect(api.callsOf('subagents.prompt')).toMatchObject([
+      { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+    ])
+    expect(api.callsOf('subagents.interruptByParent')).toEqual([
+      { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
+    ])
     expect(api.callsOf('session.follow')).toEqual([
       {
         address: {
@@ -318,11 +324,35 @@ describe('prompt and cancel errors', () => {
       },
     ])
     expect(api.callsOf('subagent.history')).toEqual([])
-    expect(api.callsOf('subagents.prompt')).toEqual([])
-    expect(api.callsOf('subagents.interruptByParent')).toEqual([])
     expect(api.callsOf('session.cancel')).toEqual([])
   })
 
+  it('delivers an image continuation to the Host, which refuses it', async () => {
+    const api = new FakeApiClient()
+    api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(
+      'subagent/attachment-unsupported',
+      'subagent continuation does not accept images',
+      { childSessionId: SID, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
+    )))
+    const session = new Session(SID, fakeRemote(api), {
+      address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+    })
+    await session.open()
+    const prompted = await session.prompt(
+      [{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }],
+      'queue',
+    )
+
+    expect(prompted).toMatchObject({
+      ok: false,
+      error: { code: 'subagent/attachment-unsupported', details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' } },
+    })
+    // The image reaches the wire unfiltered: refusing it is the Host's call.
+    expect(api.callsOf('subagents.prompt')).toMatchObject([
+      { content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] },
+    ])
+  })
+
   it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => {
     const { api, session } = makeSession()
     session.handleBlank(true)
@@ -351,21 +381,20 @@ describe('prompt and cancel errors', () => {
   it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => {
     const { api, session } = makeSession()
     session.handleBlank(true)
-    api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
+    api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' })))
     const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
     expect(result.ok).toBe(false)
-    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
     expect(session.getSnapshot()).toMatchObject({
       blank: true, promptAttempted: true, awaitingFirstTurn: true,
     })
   })
 
-  it('lands cancel failures in promptError with op=stop', async () => {
+  it('propagates a non-Remote throw raised while cancelling', async () => {
     const { api, session } = makeSession()
     api.onCancel = () => Promise.reject(new Error('cancel transport down'))
-    const result = await session.cancel()
-    expect(result.ok).toBe(false)
-    expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
+    await expect(session.cancel()).rejects.toThrow('cancel transport down')
+    expect(session.getSnapshot().promptError).toBeNull()
   })
 
   it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
@@ -400,32 +429,28 @@ describe('rename', () => {
 
   it('returns the business error untouched and folds a transport throw to internal', async () => {
     const { api, session } = makeSession()
-    api.onRename = () => Promise.resolve(err({
-      code: 'title-invalid', message: 'empty', details: { sessionId: SID },
-    } as never))
+    api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID })))
     const rejected = await session.rename('   ')
-    expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
+    expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } })
     expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
     api.onRename = () => Promise.reject(new Error('rename transport down'))
-    const folded = await session.rename('x')
-    expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
+    await expect(session.rename('x')).rejects.toThrow('rename transport down')
   })
 })
 
 describe('remaining branches', () => {
-  it('prompt transport throw folds to internal promptError', async () => {
+  it('propagates a non-Remote throw raised while prompting', async () => {
     const { api, session } = makeSession()
     api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
-    const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue')
-    expect(result.ok).toBe(false)
-    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } })
+    await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down')
+    expect(session.getSnapshot().promptError).toBeNull()
   })
 
   it('cancel business error also lands op=stop promptError', async () => {
     const { api, session } = makeSession()
-    api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } }))
+    api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' })))
     await session.cancel()
-    expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } })
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } })
   })
 
   it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
@@ -435,7 +460,7 @@ describe('remaining branches', () => {
     api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
     await session.open()
     // err result: window unchanged
-    api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
+    api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {})))
     await session.loadOlder()
     expect(eventSeqs(session)).toHaveLength(6)
     expect(session.getSnapshot().hasMore).toBe(true)
@@ -490,7 +515,7 @@ describe('remaining branches', () => {
     const snapshot = session.getSnapshot()
     expect(snapshot.openState).toBe('error')
     expect(snapshot.openError).toMatchObject({
-      code: 'internal', message: 'session event stream page did not end at its requested cursor',
+      code: 'gateway/internal', message: 'session event stream page did not end at its requested cursor',
     })
     expect(eventSeqs(session)).toEqual([])
   })
@@ -508,7 +533,7 @@ describe('remaining branches', () => {
     const { api, session } = makeSession()
     await follow(api, ev.user(0, '冷态帧'))
     expect(eventSeqs(session)).toEqual([])
-    api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
+    api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {})))
     await session.open()
     await follow(api, ev.user(0, '错态帧'))
     expect(eventSeqs(session)).toEqual([])
@@ -518,16 +543,14 @@ describe('remaining branches', () => {
     const { api, session } = makeSession()
     api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
     await session.open()
-    const failure = {
-      code: 'session-not-found',
-      message: 'session disappeared',
-      details: { sessionId: SID },
-    }
+    const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID })
 
-    api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details))
+    api.failStreams(failure)
     await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
 
-    expect(session.getSnapshot().openError).toEqual(failure)
+    expect(session.getSnapshot().openError).toMatchObject({
+      code: failure.code, message: failure.message, details: failure.details,
+    })
   })
 
   it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => {
@@ -545,10 +568,10 @@ describe('remaining branches', () => {
       follow(api, ev.user(10, '洞二')),
     ])
     await vi.waitFor(() => { expect(repairs).toBe(1) })
-    gate.reject(new Error('repair wire down'))
+    gate.reject(new RemoteError('gateway/internal', 'repair wire down', {}))
     await deliveries
     await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
-    expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' })
+    expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' })
     expect(eventSeqs(session)).toHaveLength(6)
   })
 

+ 18 - 31
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -9,6 +9,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
 import { scopeOf } from '../src/client/scope.ts'
 import type { SessionFollowFrame } from '../src/types.ts'
@@ -18,7 +19,6 @@ import {
   err,
   fakeRemote,
   ok,
-  remoteOk,
   type RuntimeRemotes,
 } from './fake-api.client.ts'
 
@@ -525,7 +525,7 @@ describe('catalog-addressed navigation', () => {
     b.api.onSubagentList = (payload) => {
       const parentSessionId = payload as SessionId
       if (parentSessionId === sid('root')) {
-        return Promise.resolve(remoteOk({
+        return Promise.resolve(ok({
           entries: [{
             kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
             activity: 'inactive', hasChildren: true,
@@ -534,7 +534,7 @@ describe('catalog-addressed navigation', () => {
         }))
       }
       if (parentSessionId === sid('child')) {
-        return Promise.resolve(remoteOk({
+        return Promise.resolve(ok({
           entries: [{
             kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
             activity: 'inactive', hasChildren: false,
@@ -542,7 +542,7 @@ describe('catalog-addressed navigation', () => {
           parentAvailable: false,
         }))
       }
-      return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
+      return Promise.resolve(ok({ entries: [], parentAvailable: false }))
     }
     await feedList(b, [
       { id: 'root' },
@@ -564,7 +564,7 @@ describe('catalog-addressed navigation', () => {
     b.api.onSubagentList = (payload) => {
       const parentSessionId = payload as SessionId
       if (parentSessionId === sid('root')) {
-        return Promise.resolve(remoteOk({
+        return Promise.resolve(ok({
           entries: [{
             kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
             activity: 'inactive', hasChildren: true,
@@ -573,7 +573,7 @@ describe('catalog-addressed navigation', () => {
         }))
       }
       if (parentSessionId === sid('child')) {
-        return Promise.resolve(remoteOk({
+        return Promise.resolve(ok({
           entries: [{
             kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
             activity: 'inactive', hasChildren: false,
@@ -581,7 +581,7 @@ describe('catalog-addressed navigation', () => {
           parentAvailable: false,
         }))
       }
-      return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
+      return Promise.resolve(ok({ entries: [], parentAvailable: false }))
     }
     await feedList(b, [{ id: 'root' }])
     await b.svc.refreshSubagents(sid('root'))
@@ -611,15 +611,12 @@ describe('create', () => {
     b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
     await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
     expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
-    b.api.onCreate = () => Promise.resolve({
-      rpcId: 'e' as never,
-      result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
-    } as never)
+    b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {})))
     const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
     expect(failure).toBeInstanceOf(SessionCreateError)
     expect(failure).toMatchObject({
       requestedSessionId: 'candidate',
-      rpcError: { code: 'internal', message: '爆了' },
+      rpcError: { code: 'gateway/internal', message: '爆了' },
     })
   })
 
@@ -637,16 +634,11 @@ describe('create', () => {
 
   it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
     const b = bench()
-    b.api.onCreate = () => Promise.resolve({
-      rpcId: 'attach' as never,
-      result: {
-        ok: false,
-        error: {
-          code: 'workspace-attach-failed', message: 'ledger unavailable',
-          details: { sessionId: sid('published'), workspaceId: 'ws' },
-        },
-      },
-    } as never)
+    b.api.onCreate = () => Promise.resolve(err(new RemoteError(
+      'session/workspace-attach-failed',
+      'ledger unavailable',
+      { sessionId: sid('published'), workspaceId: 'ws' },
+    )))
     const failure = await b.svc.create({
       workspaceId: 'ws' as never,
       sessionId: sid('published'),
@@ -655,7 +647,7 @@ describe('create', () => {
     expect(failure).toBeInstanceOf(SessionCreateError)
     expect(failure).toMatchObject({
       requestedSessionId: 'published',
-      rpcError: { code: 'workspace-attach-failed' },
+      rpcError: { code: 'session/workspace-attach-failed' },
     })
     expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
   })
@@ -723,12 +715,10 @@ describe('fork', () => {
     })
     await feedList(b, [{ id: 'source' }])
     b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
-    b.api.onRename = () => Promise.resolve(err({
-      code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
-    } as never))
+    b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
 
     await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
-      .rejects.toThrow('fork child rename failed: title-invalid: rejected')
+      .rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
     expect(b.svc.binding(sid('child'))).toBeDefined()
   })
 })
@@ -785,10 +775,7 @@ describe('blank mirror', () => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
     const session = b.svc.binding(sid('s1'))!.session
-    b.api.onPrompt = () => Promise.resolve({
-      rpcId: 'busy' as never,
-      result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } },
-    } as never)
+    b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {})))
     const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
     expect(result.ok).toBe(false)
     // No flip on failure: local stays aligned with the host authority

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

@@ -14,7 +14,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
 import { vi } from 'vitest'
 import {
-  TypertRemoteFailure,
+  RemoteError,
+  remoteErrorOf,
   type RemoteResult,
 } from '@deepseek-ai/dsh-typert-protocol'
 import SessionController from '../src/index.ts'
@@ -224,14 +225,13 @@ function remoteResult<T>(
     .catch((error: unknown) => ({
       ok: false as const,
       error: signal?.aborted === true
-        ? { code: 'cancelled', message: 'request was aborted', details: {} }
-        : error instanceof TypertRemoteFailure
-          ? error.failure
-          : {
-            code: 'internal',
-            message: error instanceof Error ? error.message : String(error),
-            details: {},
-          },
+        ? new RemoteError('gateway/cancelled', 'request was aborted', {})
+        : remoteErrorOf(error)
+          ?? new RemoteError(
+            'gateway/internal',
+            error instanceof Error ? error.message : String(error),
+            {},
+          ),
     }))
 }
 

+ 11 - 8
packages/api/session-controller/tests/transport.client.spec.ts

@@ -2,17 +2,17 @@ import { describe, expect, it, vi } from 'vitest'
 import {
   RemoteStream,
   RemoteStreamCarrierError,
-  RemoteStreamError,
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import {
   createSessionControlStream,
   SessionEventStream,
-  sessionStreamFailure,
   type SessionJournalChange,
   type SessionRemote,
 } from '../src/client/index.ts'
+import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
 import type {
   SessionAddress,
   SessionControlFrame,
@@ -73,12 +73,18 @@ function snapshot(
   }
 }
 
-function sessionClient(remote: SessionTransportRemote) {
+function sessionClient(remote: SessionTransportRemote): SessionRemotes {
   return {
     session: remote as SessionRemote,
     $stream: <Item>(options: RemoteStreamOptions<Item>) => (
       new RemoteStream(AVAILABLE_CONNECTION, options)
     ),
+    commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) },
+    subagents: {
+      list: () => Promise.reject(new Error('stream tests never read the subagent catalog')),
+      prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')),
+      interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')),
+    },
   }
 }
 
@@ -295,7 +301,7 @@ describe('Session Client stream adapters', () => {
   })
 
   it('turns a pagination failure into a typed stream failure', async () => {
-    const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const
+    const failure = new RemoteError('session/not-found', 'missing', { sessionId: 'session-1' as never })
     const remote = new ScriptedSessionRemote(
       [{ frames: [snapshot(-1, [])], hold: true }],
       [{ ok: false, error: failure }],
@@ -306,11 +312,8 @@ describe('Session Client stream adapters', () => {
     })
 
     await stream.open({})
-    await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError)
+    await expect(stream.prepend({})).rejects.toMatchObject({ code: 'session/not-found' })
     await expect(stream.open({})).rejects.toThrow('already opened')
-    expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details)))
-      .toEqual(failure)
-    expect(sessionStreamFailure(new Error('local'))).toBeUndefined()
     expect(remote.signals[0]?.aborted).toBe(false)
     expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
     await stream.dispose()

+ 16 - 16
packages/api/session-controller/tests/transport.host.spec.ts

@@ -296,7 +296,7 @@ describe('SessionHistoryController', () => {
       id: session.id,
       events: [event('fixture/start', 0), skipped, gap],
     } as unknown as Session, gap)
-    await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(followed.next()).rejects.toMatchObject({ code: 'gateway/internal' })
   })
 
   it('opens an empty source at cursor -1', async () => {
@@ -396,15 +396,15 @@ describe('SessionHistoryController', () => {
         mode: 'continuable',
       },
       throughSeq: 0,
-    }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
+    }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
     await expect(transport.page({
       address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
       throughSeq: 0,
-    }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
+    }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
     await expect(transport.page({
       address: { kind: 'session', sessionId: childSessionId },
       throughSeq: 0,
-    }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
+    }, signal)).rejects.toMatchObject({ code: 'session/agent-busy' })
   })
 
   it('preserves a cold inspection failure for the Gateway error branch', async () => {
@@ -438,10 +438,10 @@ describe('SessionHistoryController', () => {
       { address, throughSeq: -1, maxMessages: 0 },
       { address, throughSeq: -1, maxMessages: 1.5 },
     ]) {
-      await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } })
+      await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' })
     }
     await expect(transport.page({ address, throughSeq: 0 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'bad-request' } })
+      .rejects.toMatchObject({ code: 'gateway/bad-request' })
 
     const corrupt = await setup()
     const corruptId = SessionId('missing-through-seq')
@@ -455,7 +455,7 @@ describe('SessionHistoryController', () => {
     }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
     for (const maxMessages of [0, 0.5]) {
       const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]()
-      await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
+      await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' })
     }
   })
 
@@ -463,7 +463,7 @@ describe('SessionHistoryController', () => {
     const { ctx, transport } = await setup()
     const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
     await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
+      .rejects.toMatchObject({ code: 'session/not-found' })
 
     const inspect = vi.fn(() => Promise.resolve(undefined))
     ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
@@ -471,7 +471,7 @@ describe('SessionHistoryController', () => {
       inspect,
     }) as never)
     await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
+      .rejects.toMatchObject({ code: 'session/not-found' })
     await expect(transport.page({
       address: {
         kind: 'subagent',
@@ -480,7 +480,7 @@ describe('SessionHistoryController', () => {
         mode: 'continuable',
       },
       throughSeq: -1,
-    }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
+    }, signal())).rejects.toMatchObject({ code: 'subagent/not-found' })
     expect(inspect).toHaveBeenCalledTimes(2)
   })
 
@@ -494,7 +494,7 @@ describe('SessionHistoryController', () => {
       inspect: () => Promise.resolve({ meta: firstHeader, events: [] }),
     }) as never)
     await expect(first.transport.page({ address, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
+      .rejects.toMatchObject({ code: 'session/not-found' })
 
     const second = await setup()
     const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
@@ -504,7 +504,7 @@ describe('SessionHistoryController', () => {
       inspect: () => Promise.resolve({ meta: inspected, events: [] }),
     }) as never)
     await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
+      .rejects.toMatchObject({ code: 'session/not-found' })
   })
 
   it('serves cold ordinary history and validates every durable subagent descriptor state', async () => {
@@ -538,18 +538,18 @@ describe('SessionHistoryController', () => {
     const missing = await setup()
     cold(missing.ctx, childHeader, [])
     await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
+      .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } })
 
     const corrupt = await setup()
     cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
     await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
+      .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } })
 
     const ordinaryChild = await setup()
     const { origin: _origin, ...ordinaryChildHeader } = childHeader
     cold(ordinaryChild.ctx, ordinaryChildHeader, [])
     await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
-      .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
+      .rejects.toMatchObject({ code: 'subagent/unauthorized' })
   })
 
   it('reports an unavailable descriptor when an observed child has no projection value', async () => {
@@ -578,7 +578,7 @@ describe('SessionHistoryController', () => {
       address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
       throughSeq: -1,
     }, signal())).rejects.toMatchObject({
-      failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } },
+      code: 'subagent/catalog-diagnostic', details: { reason: 'unsupported' },
     })
     await ctx.fiber.dispose()
   })

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

@@ -42,6 +42,7 @@
     { "path": "../../session-query/session-query" },
     { "path": "../../skill/skill" },
     { "path": "../../subagent/subagent" },
+    { "path": "../../util/time" },
     { "path": "../../typert/protocol" },
     { "path": "../../typert/registry" },
     { "path": "../../workspace/workspace" }

+ 19 - 21
packages/api/settings-controller/src/credentials.ts

@@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
 import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
-import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { z } from 'zod'
 
 /**
@@ -30,11 +30,7 @@ const unsetRequestSchema = z.object({ ref: credentialRefSchema })
 function parseRequest<T>(method: string, schema: z.ZodType<T>, value: unknown): T {
   const parsed = schema.safeParse(value)
   if (!parsed.success) {
-    throw new TypertRemoteFailure({
-      code: 'bad-request',
-      message: `invalid payload for ${method}`,
-      details: { issues: parsed.error.issues },
-    })
+    throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues })
   }
   return parsed.data
 }
@@ -78,9 +74,10 @@ export class CredentialsController extends TypertRemoteService {
    * Describe several references for one configuration surface. Batched because
    * a settings page describes every reference its rows name at once, and one
    * round trip keeps those rows from settling separately.
-   * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.
+   * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar
+   *   rejects the whole call as `gateway/bad-request`.
    * @returns one view per requested name, keyed by that name.
-   * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted.
+   * @throws RemoteError when the request is invalid or no credential provider is mounted.
    */
   @Remote
   async describe(refs: string[]): Promise<Record<string, CredentialInfo>> {
@@ -97,7 +94,7 @@ export class CredentialsController extends TypertRemoteService {
    * this direction only: no read path returns it.
    * @param ref - reference name to store under.
    * @param value - the non-empty secret value.
-   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
   async set(ref: string, value: string): Promise<void> {
@@ -110,7 +107,7 @@ export class CredentialsController extends TypertRemoteService {
   /**
    * Remove one reference from a configuration surface.
    * @param ref - reference name to remove.
-   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
   async unset(ref: string): Promise<void> {
@@ -124,17 +121,17 @@ export class CredentialsController extends TypertRemoteService {
   private provider(): CredentialProvider {
     const credentials = this.ctx.get('credentials')
     if (credentials === undefined) {
-      throw new TypertRemoteFailure({
-        code: 'internal',
-        message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
-        details: {},
-      })
+      throw new RemoteError(
+        'gateway/internal',
+        'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
+        {},
+      )
     }
     return credentials
   }
 
   /**
-   * Run one remote write and report every refusal as `credential-rejected`
+   * Run one remote write and report every refusal as `credential/rejected`
    * carrying the seam's own message: a read-only source shadowing the reference
    * is what a configuration surface must show verbatim. Callers brand the
    * reference before entering, so a name outside the grammar never reaches this
@@ -145,11 +142,12 @@ export class CredentialsController extends TypertRemoteService {
     try {
       await write()
     } catch (error: unknown) {
-      throw new TypertRemoteFailure({
-        code: 'credential-rejected',
-        message: error instanceof Error ? error.message : String(error),
-        details: { ref },
-      })
+      throw new RemoteError(
+        'credential/rejected',
+        error instanceof Error ? error.message : String(error),
+        { ref },
+        { cause: error },
+      )
     }
   }
 }

+ 49 - 101
packages/api/settings-controller/src/index.ts

@@ -10,12 +10,8 @@
 import { dirname } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import Schema from '@deepseek-ai/schemastery'
-import {
-  InvalidPresetIdError,
-  PresetExistsError,
-  PresetNotWritableError,
-  UnknownPresetError,
-} from '@deepseek-ai/dsh-agent-presets'
+// Type-only: resolves the `agentPresets` Context augmentation this controller reads.
+import type {} from '@deepseek-ai/dsh-agent-presets'
 import {
   canOpenNativePath,
   openNativePath,
@@ -27,7 +23,7 @@ import type {
   SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
 } from '@deepseek-ai/dsh-settings/types'
 import type { JsonValue } from '@deepseek-ai/dsh-session/types'
-import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { z } from 'zod'
 import { CredentialsController } from './credentials.ts'
 import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
@@ -88,7 +84,7 @@ declare module '@deepseek-ai/cordis' {
  * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot
  * ride a response. Writes expose the settings service's merge, replacement,
  * and path-addressed operations, and classify every provider refusal as
- * `settings-conflict` or `settings-rejected` with the service's message.
+ * `settings/conflict` or `settings/rejected` with the service's message.
  */
 export class SettingsController extends TypertRemoteService {
   static Config: Schema<Config> = Schema.object({ nativeOpen: Schema.boolean() })
@@ -116,7 +112,7 @@ export class SettingsController extends TypertRemoteService {
    * Describe every registered namespace for a configuration page: redacted
    * layered values plus the serialized schema the page renders its form from.
    * @returns provider writability, local-document presence, and one view per namespace.
-   * @throws TypertRemoteFailure when no settings provider is mounted.
+   * @throws RemoteError when no settings provider is mounted.
    */
   @Remote
   describe(): SettingsDescribeValue {
@@ -143,7 +139,7 @@ export class SettingsController extends TypertRemoteService {
    * @param patch - fields to merge into the user section.
    * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
    * @returns the namespace's redacted view after the write.
-   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
   update(
@@ -160,7 +156,7 @@ export class SettingsController extends TypertRemoteService {
    * @param section - complete replacement user section.
    * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
    * @returns the namespace's redacted view after the write.
-   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
   replace(
@@ -179,7 +175,7 @@ export class SettingsController extends TypertRemoteService {
    * @param ops - the edits to apply, in order.
    * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
    * @returns the namespace's redacted view after the write.
-   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
    */
   @Remote
   async mutate(
@@ -194,29 +190,29 @@ export class SettingsController extends TypertRemoteService {
    * Materialize the provider-owned settings document and open it in a native text editor.
    * @param signal - caller lifetime; abort terminates preparation or the native command.
    * @returns confirmation after the native opener accepts the document.
-   * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails.
+   * @throws RemoteError when no document exists, preparation fails, or opening fails.
    */
   @Remote
   async openSettingsDocument(signal: AbortSignal): Promise<SettingsDocumentOpenValue> {
     const settings = this.provider()
-    if (isAborted(signal)) throw cancelled('settings document open was aborted')
+    if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
     let path: string | undefined
     try {
       path = await settings.prepareDocument()
     } catch (error: unknown) {
-      if (isAborted(signal)) throw cancelled('settings document preparation was aborted')
-      throw internal(`settings document preparation failed: ${messageOf(error)}`)
+      if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document preparation was aborted', {})
+      throw new RemoteError('gateway/internal', `settings document preparation failed: ${messageOf(error)}`, {}, { cause: error })
     }
     if (path === undefined) {
-      throw internal('settings provider has no local document to open')
+      throw new RemoteError('gateway/internal', 'settings provider has no local document to open', {})
     }
-    if (isAborted(signal)) throw cancelled('settings document open was aborted')
+    if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
     try {
       await this.openTextFile(path, signal)
       return { opened: true }
     } catch (error: unknown) {
-      if (isAborted(signal)) throw cancelled('settings document open was aborted')
-      throw internal(`path open failed: ${messageOf(error)}`)
+      if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
+      throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
     }
   }
 
@@ -225,7 +221,7 @@ export class SettingsController extends TypertRemoteService {
    * @param agentPreset - preset id resolved against Host-owned roots.
    * @param signal - caller lifetime; abort terminates the native command.
    * @returns an opened confirmation or the resolved directory for text display.
-   * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened.
+   * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened.
    */
   @Remote
   async openAgentPresetDirectory(
@@ -233,35 +229,32 @@ export class SettingsController extends TypertRemoteService {
     signal: AbortSignal,
   ): Promise<AgentPresetDirectoryOpenValue> {
     if (agentPreset.length === 0) {
-      throw new TypertRemoteFailure({
-        code: 'bad-request', message: 'agent preset id must not be empty', details: {},
-      })
+      throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {})
     }
     const presets = this.ctx.get('agentPresets')
     if (presets === undefined) {
-      throw new TypertRemoteFailure({
-        code: 'agent-preset-not-found',
-        message: 'this deployment composes no agent presets',
-        details: { agentPreset, available: [] },
-      })
+      throw new RemoteError(
+        'agent-preset/not-found',
+        'this deployment composes no agent presets',
+        { agentPreset, available: [] },
+      )
     }
-    let directory: string
-    try {
-      const preset = await presets.resolve(agentPreset)
-      if (preset.trust !== 'user') {
-        throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
-      }
-      directory = dirname(preset.path)
-    } catch (error: unknown) {
-      throw presetFailure(agentPreset, error)
+    const preset = await presets.resolve(agentPreset)
+    if (preset.trust !== 'user') {
+      throw new RemoteError(
+        'agent-preset/read-only',
+        `agent-presets: preset "${preset.id}" cannot be written: it ships with the deployment`,
+        { agentPreset: preset.id, reason: 'it ships with the deployment' },
+      )
     }
+    const directory = dirname(preset.path)
     if (!this.canOpenPath()) return { opened: false, path: directory }
     try {
       await this.openPath(directory, signal)
       return { opened: true }
     } catch (error: unknown) {
-      if (signal.aborted) throw cancelled('path open was aborted')
-      throw internal(`path open failed: ${messageOf(error)}`)
+      if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
+      throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
     }
   }
 
@@ -273,11 +266,7 @@ export class SettingsController extends TypertRemoteService {
   ): Promise<SettingsNamespaceView> {
     const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
     if (!parsed.success) {
-      throw new TypertRemoteFailure({
-        code: 'bad-request',
-        message: `invalid payload for settings.${mode}`,
-        details: { issues: parsed.error.issues },
-      })
+      throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
     }
     const settings = this.provider()
     let branded
@@ -286,7 +275,7 @@ export class SettingsController extends TypertRemoteService {
       // unregistered one does.
       branded = settingsNamespace(parsed.data.ns)
     } catch (error: unknown) {
-      throw rejected(ns, error)
+      throw new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
     }
     try {
       if (mode === 'update') await settings.update(branded, input, expectedRevision)
@@ -299,11 +288,7 @@ export class SettingsController extends TypertRemoteService {
     if (descriptor === undefined) {
       // The write committed but the namespace vanished before this read: only a
       // concurrent registrant disposal can produce it.
-      throw new TypertRemoteFailure({
-        code: 'internal',
-        message: `settings namespace "${ns}" was disposed after the ${mode}`,
-        details: {},
-      })
+      throw new RemoteError('gateway/internal', `settings namespace "${ns}" was disposed after the ${mode}`, {})
     }
     return namespaceView(descriptor)
   }
@@ -312,11 +297,11 @@ export class SettingsController extends TypertRemoteService {
   private provider(): SettingsProvider {
     const settings = this.ctx.get('settings')
     if (settings === undefined) {
-      throw new TypertRemoteFailure({
-        code: 'internal',
-        message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
-        details: {},
-      })
+      throw new RemoteError(
+        'gateway/internal',
+        'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
+        {},
+      )
     }
     return settings
   }
@@ -326,40 +311,6 @@ function messageOf(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }
 
-function internal(message: string): TypertRemoteFailure {
-  return new TypertRemoteFailure({ code: 'internal', message, details: {} })
-}
-
-function cancelled(message: string): TypertRemoteFailure {
-  return new TypertRemoteFailure({ code: 'cancelled', message, details: {} })
-}
-
-function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure {
-  if (error instanceof UnknownPresetError) {
-    return new TypertRemoteFailure({
-      code: 'agent-preset-not-found',
-      message: error.message,
-      details: { agentPreset: error.presetId, available: [...error.available] },
-    })
-  }
-  if (error instanceof PresetNotWritableError) {
-    return new TypertRemoteFailure({
-      code: 'agent-preset-read-only',
-      message: error.message,
-      details: { agentPreset, reason: error.message },
-    })
-  }
-  if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
-    return new TypertRemoteFailure({
-      code: 'agent-preset-invalid',
-      message: error.message,
-      details: { agentPreset, reason: error.message },
-    })
-  }
-  if (error instanceof TypertRemoteFailure) return error
-  return internal(`agent preset "${agentPreset}": ${String(error)}`)
-}
-
 /**
  * Classify one seam refusal. A stale writer is its own outcome, not a malformed
  * request: the client must re-read and re-apply rather than treat the write as
@@ -368,19 +319,16 @@ function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure
  * @param error - whatever the seam threw.
  * @returns the failure to raise for that refusal.
  */
-function rejected(ns: string, error: unknown): TypertRemoteFailure {
+function rejected(ns: string, error: unknown): RemoteError {
   if (error instanceof SettingsConflictError) {
-    return new TypertRemoteFailure({
-      code: 'settings-conflict',
-      message: error.message,
-      details: { ns, expected: error.expected, actual: error.actual },
-    })
+    return new RemoteError(
+      'settings/conflict',
+      error.message,
+      { ns, expected: error.expected, actual: error.actual },
+      { cause: error },
+    )
   }
-  return new TypertRemoteFailure({
-    code: 'settings-rejected',
-    message: error instanceof Error ? error.message : String(error),
-    details: { ns },
-  })
+  return new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
 }
 
 export default SettingsController

+ 19 - 39
packages/api/settings-controller/src/types.ts

@@ -7,28 +7,26 @@
  * @module @deepseek-ai/dsh-api-settings-controller/types
  */
 
-/** Stable settings failure details returned by the `settings` namespace. */
-export interface SettingsErrorDetailsMap {
-  /**
-   * Every seam refusal that is not a stale write: an unregistered or malformed
-   * namespace, a read-only provider, schema validation, storage.
-   */
-  'settings-rejected': { readonly ns: string }
-  /**
-   * The stored revision moved after the caller read it. Its own outcome rather
-   * than an invalid request: the caller must re-read and re-apply.
-   */
-  'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number }
-}
-
-/** Settings business failure carried by a rejected Remote call. */
-export type SettingsError = {
-  [Code in keyof SettingsErrorDetailsMap]: {
-    readonly code: Code
-    readonly message: string
-    readonly details: SettingsErrorDetailsMap[Code]
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /**
+     * Every seam refusal that is not a stale write: an unregistered or malformed
+     * namespace, a read-only provider, schema validation, storage.
+     */
+    'settings/rejected': { readonly ns: string }
+    /**
+     * The stored revision moved after the caller read it. Its own outcome rather
+     * than an invalid request: the caller must re-read and re-apply.
+     */
+    'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number }
+    /**
+     * The provider refused a valid credential write, for example because a
+     * read-only source shadows the reference. The details name only the
+     * reference, never the value.
+     */
+    'credential/rejected': { readonly ref: string }
   }
-}[keyof SettingsErrorDetailsMap]
+}
 
 /** Confirmation that the settings document was handed to the native editor. */
 export interface SettingsDocumentOpenValue {
@@ -39,21 +37,3 @@ export interface SettingsDocumentOpenValue {
 export type AgentPresetDirectoryOpenValue =
   | { readonly opened: true }
   | { readonly opened: false; readonly path: string }
-
-/** Stable credential failure details returned by the `credentials` namespace. */
-export interface CredentialErrorDetailsMap {
-  /**
-   * The provider refused a valid write, for example because a read-only source
-   * shadows the reference. The details name only the reference, never the value.
-   */
-  'credential-rejected': { readonly ref: string }
-}
-
-/** Credential business failure carried by a rejected Remote call. */
-export type CredentialError = {
-  [Code in keyof CredentialErrorDetailsMap]: {
-    readonly code: Code
-    readonly message: string
-    readonly details: CredentialErrorDetailsMap[Code]
-  }
-}[keyof CredentialErrorDetailsMap]

+ 10 - 13
packages/api/settings-controller/tests/credentials-controller.host.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
-import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
+import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import CredentialsController from '../src/credentials.ts'
 import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
 
@@ -60,9 +60,8 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
       () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'),
     ]) {
       const failure = await call().catch((error: unknown) => error)
-      expect(failure).toBeInstanceOf(TypertRemoteFailure)
-      expect((failure as TypertRemoteFailure).failure).toEqual({
-        code: 'internal',
+      expect(remoteErrorOf(failure)).toMatchObject({
+        code: 'gateway/internal',
         message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
         details: {},
       })
@@ -87,8 +86,7 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
       () => controller.unset('not a var'),
     ]) {
       const failure = await call().catch((error: unknown) => error)
-      expect(failure).toBeInstanceOf(TypertRemoteFailure)
-      expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+      expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
     }
   })
 
@@ -97,7 +95,7 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
     const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`)
     expect(Object.keys(await controller.describe(accepted))).toHaveLength(64)
     const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error)
-    expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+    expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
   })
 
   it('answers only the fields the view declares, whatever a provider returns', async () => {
@@ -117,12 +115,11 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
       .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } })
   })
 
-  it('reports a refused write as credential-rejected naming only the reference', async () => {
+  it('reports a refused write as credential/rejected naming only the reference', async () => {
     const controller = await boot({}, RejectingCredentials)
     const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
-    expect(failure).toBeInstanceOf(TypertRemoteFailure)
-    const { code, message, details } = (failure as TypertRemoteFailure).failure
-    expect(code).toBe('credential-rejected')
+    const { code, message, details } = remoteErrorOf(failure) ?? {}
+    expect(code).toBe('credential/rejected')
     expect(message).toContain('read-only source')
     expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
   })
@@ -130,12 +127,12 @@ describe('the credentials Remote namespace a configuration surface calls', () =>
   it('reports an empty value as bad-request', async () => {
     const controller = await boot()
     const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error)
-    expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+    expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
   })
 
   it('stringifies a refusal that is not an Error', async () => {
     const controller = await boot({}, LiteralRejectingCredentials)
     const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
-    expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused')
+    expect(remoteErrorOf(failure)?.message).toBe('the store refused')
   })
 })

+ 33 - 48
packages/api/settings-controller/tests/settings-controller.host.spec.ts

@@ -1,14 +1,9 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import {
-  InvalidPresetIdError,
-  PresetExistsError,
-  UnknownPresetError,
-} from '@deepseek-ai/dsh-agent-presets'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
-import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError, remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import SettingsController from '../src/index.ts'
 import { MemorySettings } from '../../../settings/settings/tests/memory.ts'
 
@@ -103,9 +98,8 @@ describe('the settings Remote namespace a configuration page calls', () => {
     ]
     for (const call of calls) {
       const failure = await Promise.resolve().then(call).catch((error: unknown) => error)
-      expect(failure).toBeInstanceOf(TypertRemoteFailure)
-      expect((failure as TypertRemoteFailure).failure).toEqual({
-        code: 'internal',
+      expect(remoteErrorOf(failure)).toMatchObject({
+        code: 'gateway/internal',
         message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
         details: {},
       })
@@ -186,16 +180,15 @@ describe('the settings Remote namespace a configuration page calls', () => {
     expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }])
   })
 
-  it('refuses a stale write as settings-conflict carrying both revisions', async () => {
+  it('refuses a stale write as settings/conflict carrying both revisions', async () => {
     const { controller } = await boot()
     const held = controller.describe().namespaces[0]!.revision
     await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held)
     const failure = await controller
       .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held)
       .catch((error: unknown) => error)
-    expect(failure).toBeInstanceOf(TypertRemoteFailure)
-    const { code, details } = (failure as TypertRemoteFailure).failure
-    expect(code).toBe('settings-conflict')
+    const { code, details } = remoteErrorOf(failure) ?? {}
+    expect(code).toBe('settings/conflict')
     expect(details).toMatchObject({ ns: 'ui-test', expected: held })
   })
 
@@ -204,8 +197,8 @@ describe('the settings Remote namespace a configuration page calls', () => {
     for (const ns of ['Not A Namespace', 'unregistered']) {
       const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined)
         .catch((error: unknown) => error)
-      expect((failure as TypertRemoteFailure).failure).toMatchObject({
-        code: 'settings-rejected',
+      expect(remoteErrorOf(failure)).toMatchObject({
+        code: 'settings/rejected',
         details: { ns },
       })
     }
@@ -219,17 +212,16 @@ describe('the settings Remote namespace a configuration page calls', () => {
       () => controller.mutate('', [], undefined),
     ]) {
       const failure = await call().catch((error: unknown) => error)
-      expect(failure).toBeInstanceOf(TypertRemoteFailure)
-      expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+      expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' })
     }
   })
 
-  it('reports a refused write as settings-rejected carrying the seam message', async () => {
+  it('reports a refused write as settings/rejected carrying the seam message', async () => {
     const { controller } = await boot(RefusingSettings)
     const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
       .catch((error: unknown) => error)
-    const { code, message } = (failure as TypertRemoteFailure).failure
-    expect(code).toBe('settings-rejected')
+    const { code, message } = remoteErrorOf(failure) ?? {}
+    expect(code).toBe('settings/rejected')
     expect(message).toContain('read-only in this deployment')
   })
 
@@ -237,15 +229,15 @@ describe('the settings Remote namespace a configuration page calls', () => {
     const { controller } = await boot(LiteralRefusingSettings)
     const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
       .catch((error: unknown) => error)
-    expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked')
+    expect(remoteErrorOf(failure)?.message).toBe('the document is locked')
   })
 
   it('reports a namespace disposed between the write and its read-back', async () => {
     const { controller } = await boot(VanishingSettings)
     const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
       .catch((error: unknown) => error)
-    const { code, message } = (failure as TypertRemoteFailure).failure
-    expect(code).toBe('internal')
+    const { code, message } = remoteErrorOf(failure) ?? {}
+    expect(code).toBe('gateway/internal')
     expect(message).toContain('was disposed after the mutate')
   })
 
@@ -265,13 +257,13 @@ describe('the settings Remote namespace a configuration page calls', () => {
   it('preserves settings-document absence, failure, and cancellation', async () => {
     const absent = await boot()
     const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal)
-    await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(missingDocument).rejects.toMatchObject({ code: 'gateway/internal' })
     await expect(missingDocument).rejects.toThrow('no local document')
 
     const failed = await boot(DocumentSettings)
     vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed'))
     const failedRead = failed.controller.openSettingsDocument(new AbortController().signal)
-    await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } })
+    await expect(failedRead).rejects.toMatchObject({ code: 'gateway/internal' })
     await expect(failedRead).rejects.toThrow('read failed')
 
     const cancelled = new AbortController()
@@ -279,7 +271,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
     const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument')
     prepare.mockClear()
     await expect(failed.controller.openSettingsDocument(cancelled.signal))
-      .rejects.toMatchObject({ failure: { code: 'cancelled' } })
+      .rejects.toMatchObject({ code: 'gateway/cancelled' })
     expect(prepare).not.toHaveBeenCalled()
   })
 
@@ -296,7 +288,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
     abort.abort(new Error('cancelled'))
     prepared.resolve('/tmp/settings.yaml')
 
-    await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } })
+    await expect(opening).rejects.toMatchObject({ code: 'gateway/cancelled' })
     expect(openTextFile).not.toHaveBeenCalled()
   })
 
@@ -309,9 +301,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
     })
 
     await expect(controller.openSettingsDocument(new AbortController().signal))
-      .rejects.toMatchObject({
-        failure: { code: 'internal', message: 'path open failed: no default editor' },
-      })
+      .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: no default editor' })
   })
 
   it('classifies cancellation while preparing or opening the settings document', async () => {
@@ -324,7 +314,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
     })
     const preparingController = new SettingsController(preparing)
     await expect(preparingController.openSettingsDocument(prepareAbort.signal))
-      .rejects.toMatchObject({ failure: { code: 'cancelled' } })
+      .rejects.toMatchObject({ code: 'gateway/cancelled' })
 
     const opening = new Context()
     await opening.plugin(DocumentSettings)
@@ -337,7 +327,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
       },
     })
     await expect(openingController.openSettingsDocument(openAbort.signal))
-      .rejects.toMatchObject({ failure: { code: 'cancelled' } })
+      .rejects.toMatchObject({ code: 'gateway/cancelled' })
   })
 
   it('opens a user Agent preset directory or returns its path without a native opener', async () => {
@@ -391,11 +381,11 @@ describe('the settings Remote namespace a configuration page calls', () => {
     } as never)
     const controller = new SettingsController(ctx)
     await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal))
-      .rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } })
+      .rejects.toMatchObject({ code: 'agent-preset/read-only' })
 
     const missing = new SettingsController(new Context())
     await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal))
-      .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } })
+      .rejects.toMatchObject({ code: 'agent-preset/not-found' })
   })
 
   it('rejects an empty Agent preset id before resolving a provider', async () => {
@@ -405,23 +395,20 @@ describe('the settings Remote namespace a configuration page calls', () => {
     const controller = new SettingsController(ctx)
 
     await expect(controller.openAgentPresetDirectory('', new AbortController().signal))
-      .rejects.toMatchObject({ failure: { code: 'bad-request' } })
+      .rejects.toMatchObject({ code: 'gateway/bad-request' })
     expect(resolve).not.toHaveBeenCalled()
   })
 
-  it.each([
-    [new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'],
-    [new InvalidPresetIdError('../bad'), 'agent-preset-invalid'],
-    [new PresetExistsError('taken'), 'agent-preset-invalid'],
-    [new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'],
-    ['unexpected preset failure', 'internal'],
-  ] as const)('maps Agent preset resolution failure %#', async (error, code) => {
+  it('raises an Agent preset resolution failure as the roster reported it', async () => {
     const ctx = new Context()
-    ctx.provide('agentPresets', { resolve: async () => { throw error } } as never)
+    const reported = new RemoteError('agent-preset/not-found', 'no such preset', {
+      agentPreset: 'mine', available: ['standard'],
+    })
+    ctx.provide('agentPresets', { resolve: async () => { throw reported } } as never)
     const controller = new SettingsController(ctx)
 
     await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal))
-      .rejects.toMatchObject({ failure: { code } })
+      .rejects.toBe(reported)
   })
 
   it('classifies cancellation and non-Error failures from the preset opener', async () => {
@@ -441,10 +428,8 @@ describe('the settings Remote namespace a configuration page calls', () => {
     const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath })
 
     await expect(controller.openAgentPresetDirectory('first', abort.signal))
-      .rejects.toMatchObject({ failure: { code: 'cancelled' } })
+      .rejects.toMatchObject({ code: 'gateway/cancelled' })
     await expect(controller.openAgentPresetDirectory('second', new AbortController().signal))
-      .rejects.toMatchObject({
-        failure: { code: 'internal', message: 'path open failed: desktop unavailable' },
-      })
+      .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' })
   })
 })

+ 5 - 10
packages/api/workspace-controller/src/client/index.ts

@@ -7,7 +7,7 @@ import {
   type ClientRemote,
 } from '@deepseek-ai/dsh-api-gateway/client'
 import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts'
-import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts'
+import type { WorkspaceFollowSink } from './model.ts'
 import { ClientWorkspaceModel } from './model.ts'
 import { WorkspaceController } from './service.ts'
 
@@ -19,10 +19,6 @@ export { WorkspaceController, WorkspaceCreateError } from './service.ts'
 export type { IWorkspaces, WorkspaceSource } from './service.ts'
 export type { WorkspaceId, WorkspaceView } from '../types.ts'
 
-type WorkspaceStreamRemote = Pick<ClientRemote, '$stream'> & {
-  readonly workspace: WorkspaceRemote
-}
-
 type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
 
 /** Gateway-owned snapshot stream configured for Workspace state. */
@@ -46,10 +42,9 @@ export const inject = ['remote', 'remote.workspace']
  * @param ctx - Client root Context.
  */
 export function apply(ctx: Context): void {
-  const remote = ctx.remote as WorkspaceStreamRemote
-  const model = new ClientWorkspaceModel(remote.workspace)
+  const model = new ClientWorkspaceModel(ctx.remote.workspace)
   new WorkspaceController(ctx, model)
-  const control = createWorkspaceStateStream(remote, {
+  const control = createWorkspaceStateStream(ctx.remote, {
     accept: model,
     carrierFailed: () => { model.handleCarrierFailure() },
     failed: (error) => { model.handleStreamFailure(error) },
@@ -73,12 +68,12 @@ export interface WorkspaceStateStreamOptions {
 
 /**
  * Create the reconnecting Workspace state stream.
- * @param remote - generated Workspace namespace and Gateway stream factory.
+ * @param remote - Client Remote face carrying the Workspace namespace and the stream factory.
  * @param options - Workspace state destinations.
  * @returns an unstarted stream owned by the Client Workspace runtime.
  */
 export function createWorkspaceStateStream(
-  remote: WorkspaceStreamRemote,
+  remote: ClientRemote,
   options: WorkspaceStateStreamOptions,
 ): WorkspaceStateStream {
   const stream = remote.$stream<WorkspaceFollowFrame>({

+ 8 - 32
packages/api/workspace-controller/src/client/model.ts

@@ -2,6 +2,7 @@
 
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
+import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
 import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
 import type {
   WorkspaceArchiveSessionRequest,
@@ -82,12 +83,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
    * @returns generated Remote result.
    */
   async create(input: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
-    let result: RemoteResult<WorkspaceCreateValue>
-    try {
-      result = await this.remote.create(input)
-    } catch (error) {
-      result = failureResult(error)
-    }
+    const result = await this.remote.create(input)
     if (result.ok) this.upsert(result.value.workspace)
     return result
   }
@@ -129,19 +125,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
     const frameGeneration = this.orderFrameGeneration
     const localOrder = this.items.map(workspace => workspace.workspaceId)
     this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
-    let result: RemoteResult<WorkspaceOrderValue>
-    try {
-      result = await this.remote.insertBefore({
-        workspaceId,
-        ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
-      })
-    } catch (error) {
-      if (requestGeneration === this.orderRequestGeneration
-        && frameGeneration === this.orderFrameGeneration) {
-        this.installOrder(this.committedOrder)
-      }
-      throw error
-    }
+    const result = await this.remote.insertBefore({
+      workspaceId,
+      ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
+    })
     if (requestGeneration === this.orderRequestGeneration
       && frameGeneration === this.orderFrameGeneration) {
       this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok)
@@ -233,8 +220,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
    * @param error - terminal stream failure.
    */
   handleStreamFailure(error: unknown): void {
+    if (!isRemoteFailure(error)) throw error
     this.state = 'error'
-    this.error = failureOf(error)
+    this.error = error
     this.invalidate()
   }
 
@@ -369,15 +357,3 @@ function insertIdBefore(
   const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
   return [...without.slice(0, at), id, ...without.slice(at)]
 }
-
-function failureResult<T>(error: unknown): RemoteResult<T> {
-  return { ok: false, error: failureOf(error) }
-}
-
-function failureOf(error: unknown): RemoteFailure {
-  return {
-    code: 'internal',
-    message: error instanceof Error ? error.message : String(error),
-    details: {},
-  }
-}

+ 1 - 1
packages/api/workspace-controller/src/client/service.ts

@@ -11,7 +11,7 @@ import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts'
 export class WorkspaceCreateError extends Error {
   override readonly name = 'WorkspaceCreateError'
 
-  /** @param rpcError - Host business or folded transport failure. */
+  /** @param rpcError - Host business or folded carrier failure. */
   constructor(readonly rpcError: RemoteFailure) {
     super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
   }

+ 15 - 25
packages/api/workspace-controller/src/commands.ts

@@ -8,7 +8,7 @@ import {
   WorkspaceOrderInvalidError,
   WorkspaceUnknownSessionError,
 } from '@deepseek-ai/dsh-workspace'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import { workspaceView } from './feed.ts'
 import type {
   WorkspaceArchiveSessionRequest,
@@ -46,11 +46,12 @@ export class WorkspaceCommands {
         const workspace = await this.ctx.workspaceRegistry.create(request.path)
         return { workspace: workspaceView(workspace), created: true }
       } catch (error) {
-        if (error instanceof TypertRemoteFailure) throw error
-        throw failure(
-          'workspace-invalid-path',
+        if (remoteErrorOf(error) !== undefined) throw error
+        throw new RemoteError(
+          'workspace/invalid-path',
           `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`,
           { path: request.path },
+          { cause: error },
         )
       }
     })
@@ -64,19 +65,15 @@ export class WorkspaceCommands {
   rename(request: WorkspaceRenameRequest): Promise<WorkspaceValue> {
     const title = request.title.trim()
     if (title === '') {
-      return Promise.reject(failure(
-        'bad-request',
-        'Workspace rename requires a non-blank title',
-        {},
-      ))
+      return Promise.reject(new RemoteError('gateway/bad-request', 'Workspace rename requires a non-blank title', {}))
     }
     return this.enqueue(async () => {
       const workspace = this.requireWorkspace(request.workspaceId)
       if (title !== workspace.title) {
         if (this.ctx.workspaceRegistry.list().some(candidate =>
           candidate.id !== workspace.id && candidate.title === title)) {
-          throw failure(
-            'workspace-name-conflict',
+          throw new RemoteError(
+            'workspace/name-conflict',
             `Workspace name '${title}' is already in use`,
             { name: title },
           )
@@ -132,8 +129,8 @@ export class WorkspaceCommands {
       await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId)
     } catch (error) {
       if (!(error instanceof WorkspaceMoveInvalidError)) throw error
-      throw failure(
-        'workspace-move-invalid',
+      throw new RemoteError(
+        'workspace/move-invalid',
         error.message,
         {
           workspaceId: request.workspaceId,
@@ -142,6 +139,7 @@ export class WorkspaceCommands {
             ? {}
             : { beforeSessionId: request.beforeSessionId },
         },
+        { cause: error },
       )
     }
     return { workspace: workspaceView(workspace) }
@@ -157,7 +155,7 @@ export class WorkspaceCommands {
       await this.ctx.workspaceRegistry.archiveSession(request.sessionId)
     } catch (error) {
       if (!(error instanceof WorkspaceUnknownSessionError)) throw error
-      throw failure('session-not-found', error.message, { sessionId: request.sessionId })
+      throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
     }
     return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
   }
@@ -175,22 +173,14 @@ export class WorkspaceCommands {
   }
 }
 
-function workspaceNotFound(workspaceId: WorkspaceId): TypertRemoteFailure {
-  return failure(
-    'workspace-not-found',
+function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<'workspace/not-found'> {
+  return new RemoteError(
+    'workspace/not-found',
     `Workspace "${workspaceId}" not found`,
     { workspaceId },
   )
 }
 
-function failure(
-  code: string,
-  message: string,
-  details: object,
-): TypertRemoteFailure {
-  return new TypertRemoteFailure({ code, message, details })
-}
-
 function errorMessage(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }

+ 29 - 26
packages/api/workspace-controller/src/directory-picker.ts

@@ -6,12 +6,14 @@
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
 import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
-import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker'
+import type {
+  DirectoryPickerCapabilities, DirectoryPickerErrorCode,
+} from '@deepseek-ai/dsh-host-directory-picker'
 // The seam owns the listing declaration; the generator requires the reference
 // site to name that package rather than this package's re-export of it.
 import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
-import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
-import type { DirectoryPickerErrorDetailsMap } from './types.ts'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type { RemoteErrorCode } from '@deepseek-ai/dsh-typert-protocol'
 
 const createDirectoryRequestSchema = z.object({
   path: z.string(),
@@ -86,8 +88,8 @@ export class DirectoryPickerController extends TypertRemoteService {
   async createDirectory(path: string, name: string): Promise<string> {
     const request = createDirectoryRequestSchema.safeParse({ path, name })
     if (!request.success) {
-      throw pickerFailureOf(
-        'bad-request',
+      throw new RemoteError(
+        'gateway/bad-request',
         'invalid payload for host.createDirectory',
         { issues: request.error.issues },
       )
@@ -107,8 +109,8 @@ export class DirectoryPickerController extends TypertRemoteService {
   ): DirectoryPickerCapabilities[Kind] {
     const capability = this.ctx.directoryPicker.capability()
     if (capability.kind !== kind) {
-      throw pickerFailureOf(
-        'directory-picker-unavailable',
+      throw new RemoteError(
+        'directory-picker/unavailable',
         `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`,
         { capability: capability.kind },
       )
@@ -118,19 +120,15 @@ export class DirectoryPickerController extends TypertRemoteService {
 }
 
 /**
- * Raise one entry of the picking wire failure vocabulary.
- * @param code - the failure code a caller discriminates on.
- * @param message - operator-facing description.
- * @param details - the payload this code carries.
- * @returns the failure to throw across the Remote boundary.
+ * Wire code answered for each seam browse failure. The seam's closed codes are
+ * its own local vocabulary, so this controller owns the projection onto the
+ * `directory-picker/*` codes a Remote caller discriminates on.
  */
-function pickerFailureOf<Code extends keyof DirectoryPickerErrorDetailsMap>(
-  code: Code,
-  message: string,
-  details: DirectoryPickerErrorDetailsMap[Code],
-): TypertRemoteFailure {
-  return new TypertRemoteFailure({ code, message, details })
-}
+const BROWSE_FAILURE_CODES = {
+  'directory-unreadable': 'directory-picker/unreadable',
+  'directory-exists': 'directory-picker/exists',
+  'directory-create-failed': 'directory-picker/create-failed',
+} as const satisfies Record<DirectoryPickerErrorCode, RemoteErrorCode>
 
 /**
  * Classify a browse-primitive rejection: the seam's own closed codes carry the
@@ -138,16 +136,21 @@ function pickerFailureOf<Code extends keyof DirectoryPickerErrorDetailsMap>(
  * @param error - the primitive's rejection.
  * @returns the failure to throw across the Remote boundary.
  */
-function browseFailure(error: unknown): TypertRemoteFailure {
+function browseFailure(error: unknown): RemoteError {
   if (error instanceof DirectoryPickerError) {
-    return pickerFailureOf(error.code, error.message, { path: error.path })
+    return new RemoteError(
+      BROWSE_FAILURE_CODES[error.code],
+      error.message,
+      { path: error.path },
+      { cause: error },
+    )
   }
-  return pickerFailureOf('internal', errorMessage(error), {})
+  return new RemoteError('gateway/internal', errorMessage(error), {}, { cause: error })
 }
 
 /**
  * Classify a cancellable primitive's rejection. An abort is the caller's own
- * timeout or disconnect, not a backend failure, so it answers `cancelled`
+ * timeout or disconnect, not a backend failure, so it answers `gateway/cancelled`
  * before the business classification runs.
  * @param error - the primitive's rejection.
  * @param signal - the caller lifetime the primitive ran under.
@@ -160,10 +163,10 @@ function cancellableFailure(
   signal: AbortSignal,
   cancelled: string,
   failed?: string,
-): TypertRemoteFailure {
-  if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {})
+): RemoteError {
+  if (signal.aborted) return new RemoteError('gateway/cancelled', cancelled, {}, { cause: error })
   if (failed === undefined) return browseFailure(error)
-  return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {})
+  return new RemoteError('gateway/internal', `${failed}: ${errorMessage(error)}`, {}, { cause: error })
 }
 
 function errorMessage(error: unknown): string {

+ 20 - 41
packages/api/workspace-controller/src/types.ts

@@ -7,9 +7,6 @@
 
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
-import type { z as zCore } from 'zod'
-
-type ZodIssue = zCore.core.$ZodIssue
 
 export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
@@ -29,45 +26,27 @@ export interface WorkspaceView {
   readonly updatedAt: string
 }
 
-/** Stable Workspace failure details returned by unary methods. */
-export interface WorkspaceErrorDetailsMap {
-  'bad-request': Record<never, never>
-  'workspace-invalid-path': { readonly path: string }
-  'workspace-not-found': { readonly workspaceId: WorkspaceId }
-  'workspace-name-conflict': { readonly name: string }
-  'workspace-move-invalid': {
-    readonly workspaceId: WorkspaceId
-    readonly sessionId: SessionId
-    readonly beforeSessionId?: SessionId
-  }
-  'session-not-found': { readonly sessionId: SessionId }
-}
-
-/** Workspace business failure returned without throwing a carrier error. */
-export type WorkspaceError = {
-  [Code in keyof WorkspaceErrorDetailsMap]: {
-    readonly code: Code
-    readonly message: string
-    readonly details: WorkspaceErrorDetailsMap[Code]
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** The requested directory cannot back a Workspace. */
+    'workspace/invalid-path': { readonly path: string }
+    /** Another Workspace already uses the requested name. */
+    'workspace/name-conflict': { readonly name: string }
+    /** The Session or its anchor is not in the Workspace's manual order. */
+    'workspace/move-invalid': {
+      readonly workspaceId: WorkspaceId
+      readonly sessionId: SessionId
+      readonly beforeSessionId?: SessionId
+    }
+    /** The verb needs an interaction the composed backend does not serve. */
+    'directory-picker/unavailable': { readonly capability: string }
+    /** The target is not fully qualified, or the backend cannot list it. */
+    'directory-picker/unreadable': { readonly path: string }
+    /** A child of that name is already there. */
+    'directory-picker/exists': { readonly path: string }
+    /** The parent is not fully qualified, the name is not one segment, or creation failed. */
+    'directory-picker/create-failed': { readonly path: string }
   }
-}[keyof WorkspaceErrorDetailsMap]
-
-/** Stable directory-picking failure details returned by the picking wire verbs. */
-export interface DirectoryPickerErrorDetailsMap {
-  /** The directory creation request violates its semantic input constraints. */
-  'bad-request': { readonly issues: ZodIssue[] }
-  /** The verb needs an interaction the composed backend does not serve. */
-  'directory-picker-unavailable': { readonly capability: string }
-  /** The target is not fully qualified, or the backend cannot list it. */
-  'directory-unreadable': { readonly path: string }
-  /** A child of that name is already there. */
-  'directory-exists': { readonly path: string }
-  /** The parent is not fully qualified, the name is not one segment, or creation failed. */
-  'directory-create-failed': { readonly path: string }
-  /** The caller's own timeout or disconnect ended the chooser or the scan. */
-  cancelled: Record<never, never>
-  /** A backend failure with no seam code of its own. */
-  internal: Record<never, never>
 }
 
 /** Existing directory requested for Workspace adoption. */

+ 15 - 14
packages/api/workspace-controller/tests/directory-picker.host.spec.ts

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
 import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import { DirectoryPickerController } from '../src/directory-picker.ts'
 
 const roots: Context[] = []
@@ -60,8 +60,9 @@ async function refused(call: Promise<unknown>): Promise<{ code: string; message:
   try {
     await call
   } catch (error: unknown) {
-    if (!(error instanceof TypertRemoteFailure)) throw error
-    return { ...error.failure }
+    const failure = remoteErrorOf(error)
+    if (failure === undefined) throw error
+    return { code: failure.code, message: failure.message, details: failure.details }
   }
   throw new Error('the call was expected to be refused')
 }
@@ -85,18 +86,18 @@ describe('directoryPicker pick Remote', () => {
     const abort = new AbortController()
     const pending = refused(picker.pick(abort.signal))
     abort.abort()
-    expect((await pending).code).toBe('cancelled')
+    expect((await pending).code).toBe('gateway/cancelled')
 
     const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
     const failure = await refused(broken.pick(new AbortController().signal))
-    expect(failure.code).toBe('internal')
+    expect(failure.code).toBe('gateway/internal')
     expect(failure.message).toContain('no chooser installed')
   })
 
   it('refuses the native verb under a browse composition', async () => {
     const picker = await harness(BROWSE_STUB)
     const failure = await refused(picker.pick(new AbortController().signal))
-    expect(failure.code).toBe('directory-picker-unavailable')
+    expect(failure.code).toBe('directory-picker/unavailable')
     expect(failure.message).toContain('needs the native capability')
     expect(failure.details).toEqual({ capability: 'browse' })
   })
@@ -115,12 +116,12 @@ describe('directoryPicker browse Remotes', () => {
   it('maps the seam\'s typed failures and folds unknown throws to internal', async () => {
     const picker = await harness(BROWSE_STUB)
     expect(await refused(picker.list('/denied', new AbortController().signal)))
-      .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } })
-    expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists')
-    expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal')
+      .toMatchObject({ code: 'directory-picker/unreadable', details: { path: '/denied' } })
+    expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-picker/exists')
+    expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('gateway/internal')
 
     const thrown = await refused(picker.createDirectory('/home/user', 'gone'))
-    expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' })
+    expect(thrown).toMatchObject({ code: 'gateway/internal', message: 'the volume vanished' })
   })
 
   it('rejects invalid child names before capability dispatch', async () => {
@@ -134,7 +135,7 @@ describe('directoryPicker browse Remotes', () => {
     for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
       const failure = await refused(picker.createDirectory('/home/user', name))
       expect(failure).toMatchObject({
-        code: 'bad-request',
+        code: 'gateway/bad-request',
         message: 'invalid payload for host.createDirectory',
       })
       expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true)
@@ -153,14 +154,14 @@ describe('directoryPicker browse Remotes', () => {
     const abort = new AbortController()
     const pending = refused(picker.list(undefined, abort.signal))
     abort.abort()
-    expect((await pending).code).toBe('cancelled')
+    expect((await pending).code).toBe('gateway/cancelled')
   })
 
   it('refuses the browse verbs under a native composition', async () => {
     const picker = await harness()
     expect(await refused(picker.list(undefined, new AbortController().signal)))
-      .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
+      .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } })
     expect(await refused(picker.createDirectory('/x', 'y')))
-      .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
+      .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } })
   })
 })

+ 25 - 45
packages/api/workspace-controller/tests/model.client.spec.ts

@@ -15,11 +15,10 @@ import type {
   WorkspaceOrderValue,
   WorkspaceRenameRequest,
   WorkspaceValue,
-  WorkspaceError,
   WorkspaceId,
   WorkspaceView,
 } from '../src/types.ts'
-import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 const sid = (id: string): SessionId => id as SessionId
@@ -44,7 +43,7 @@ function remoteOk<T>(value: T): RemoteResult<T> {
   return { ok: true, value }
 }
 
-function workspaceError(error: WorkspaceError): RemoteResult<never> {
+function workspaceError(error: RemoteFailure): RemoteResult<never> {
   return { ok: false, error }
 }
 
@@ -158,17 +157,17 @@ describe('ClientWorkspaceModel', () => {
     model.handleCarrierFailure()
     expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null })
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible'])
-    model.handleStreamFailure(new Error('wire down'))
+    model.handleStreamFailure(new RemoteError('gateway/internal', 'wire down', {}))
     expect(model.getSnapshot()).toMatchObject({
-      phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' },
+      phase: 'ready', state: 'error', error: { code: 'gateway/internal', message: 'wire down' },
     })
-    model.handleStreamFailure('plain failure')
-    expect(model.getSnapshot().error?.message).toBe('plain failure')
+    // An unmarked value never crosses the stream boundary: it is a local fault.
+    expect(() => { model.handleStreamFailure('plain failure') }).toThrow()
     baseline(model, [workspace('restored')])
     expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null })
   })
 
-  it('creates by path, prepends the returned row, and folds rejected calls', async () => {
+  it('creates by path and prepends the returned row', async () => {
     const remote = new FakeWorkspaceRemote()
     const model = modelFor(remote)
     remote.onCreate = request => Promise.resolve(remoteOk({
@@ -178,11 +177,6 @@ describe('ClientWorkspaceModel', () => {
     await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
     expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } })
     expect(model.getSnapshot().items[0]?.workspaceId).toBe('created')
-
-    remote.onCreate = () => Promise.reject(new Error('create transport'))
-    await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({
-      ok: false, error: { code: 'internal', message: 'create transport' },
-    })
   })
 
   it('lets newer stream order outrank unary echoes and rolls failures back', async () => {
@@ -199,22 +193,16 @@ describe('ClientWorkspaceModel', () => {
     await pending
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
 
-    remote.onInsertBefore = () => Promise.resolve(workspaceError({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') },
-    }))
+    remote.onInsertBefore = () => Promise.resolve(workspaceError(
+      new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('three') }),
+    ))
     const rejected = model.insertBefore(wid('three'))
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
     await expect(rejected).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
-
-    remote.onInsertBefore = () => Promise.reject(new Error('transport down'))
-    const disconnected = model.insertBefore(wid('three'), wid('one'))
-    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
-    await expect(disconnected).rejects.toThrow('transport down')
-    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
   })
 
-  it('keeps a newer optimistic reorder when an older transport call rejects', async () => {
+  it('keeps a newer optimistic reorder when an older refused call settles', async () => {
     const remote = new FakeWorkspaceRemote()
     const model = modelFor(remote)
     baseline(model, [workspace('one'), workspace('two'), workspace('three')])
@@ -225,8 +213,10 @@ describe('ClientWorkspaceModel', () => {
 
     const first = model.insertBefore(wid('three'), wid('one'))
     const second = model.insertBefore(wid('two'), wid('three'))
-    firstGate.reject(new Error('first transport failed'))
-    await expect(first).rejects.toThrow('first transport failed')
+    firstGate.resolve(workspaceError(
+      new RemoteError('workspace/not-found', 'first refused', { workspaceId: wid('three') }),
+    ))
+    await expect(first).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
     secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] }))
     await expect(second).resolves.toMatchObject({ ok: true })
@@ -244,14 +234,10 @@ describe('ClientWorkspaceModel', () => {
     const first = model.insertBefore(wid('three'), wid('one'))
     const second = model.insertBefore(wid('two'), wid('three'))
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
-    firstGate.resolve(workspaceError({
-      code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') },
-    }))
+    firstGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'first rejected', { workspaceId: wid('three') })))
     await expect(first).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
-    secondGate.resolve(workspaceError({
-      code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') },
-    }))
+    secondGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'second rejected', { workspaceId: wid('two') })))
     await expect(second).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
   })
@@ -284,15 +270,11 @@ describe('ClientWorkspaceModel', () => {
     const model = modelFor(remote)
     baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')])
 
-    remote.onRename = () => Promise.resolve(workspaceError({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') },
-    }))
+    remote.onRename = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
     await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().items[0]?.title).toBe('one')
 
-    remote.onDelete = () => Promise.resolve(workspaceError({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') },
-    }))
+    remote.onDelete = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
     await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().items).toHaveLength(1)
 
@@ -306,11 +288,9 @@ describe('ClientWorkspaceModel', () => {
       request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' },
     })
 
-    remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({
-      code: 'workspace-move-invalid',
-      message: 'invalid move',
-      details: { workspaceId: wid('one'), sessionId: sid('second') },
-    }))
+    remote.onInsertSessionBefore = () => Promise.resolve(workspaceError(
+      new RemoteError('workspace/move-invalid', 'invalid move', { workspaceId: wid('one'), sessionId: sid('second') }),
+    ))
     await expect(model.insertSessionBefore(wid('one'), sid('second')))
       .resolves.toMatchObject({ ok: false })
     expect(remote.calls).toContainEqual({
@@ -318,9 +298,9 @@ describe('ClientWorkspaceModel', () => {
       request: { workspaceId: 'one', sessionId: 'second' },
     })
 
-    remote.onArchiveSession = () => Promise.resolve(workspaceError({
-      code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') },
-    }))
+    remote.onArchiveSession = () => Promise.resolve(workspaceError(
+      new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
+    ))
     await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
     expect(model.getSnapshot().archivedSessionIds).toEqual(['archived'])
     remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))

+ 19 - 32
packages/api/workspace-controller/tests/transport.client.spec.ts

@@ -3,11 +3,12 @@ import { describe, expect, it, vi } from 'vitest'
 import {
   RemoteStream,
   RemoteStreamCarrierError,
+  type ClientRemote,
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import * as WorkspaceClientPlugin from '../src/client/index.ts'
 import {
   ClientWorkspaceModel,
@@ -29,7 +30,6 @@ import type {
   WorkspaceInsertSessionBeforeRequest,
   WorkspaceOrderValue,
   WorkspaceRenameRequest,
-  WorkspaceError,
   WorkspaceId,
   WorkspaceValue,
   WorkspaceView,
@@ -45,11 +45,11 @@ const AVAILABLE_CONNECTION = {
 function workspaceClient(
   remote: WorkspaceRemote,
   connection: Pick<ConnectionHandle, 'generation'> = AVAILABLE_CONNECTION,
-) {
+): ClientRemote {
   return {
     workspace: remote,
     $stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
-  }
+  } as unknown as ClientRemote
 }
 
 interface Generation {
@@ -94,7 +94,7 @@ function remoteOk<T>(value: T): RemoteResult<T> {
   return { ok: true, value }
 }
 
-function remoteFailure(error: WorkspaceError): RemoteResult<never> {
+function remoteFailure(error: RemoteFailure): RemoteResult<never> {
   return { ok: false, error }
 }
 
@@ -250,7 +250,7 @@ describe('Workspace Controller Client apply', () => {
         phase: 'ready',
         state: 'error',
         items: [{ workspaceId: 'fresh' }],
-        error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' },
+        error: { code: 'gateway/internal', message: 'Workspace state stream emitted more than one opening snapshot' },
       })
     })
 
@@ -445,40 +445,27 @@ describe('WorkspaceController', () => {
   it('maps generated business failures to the command facade errors', async () => {
     const remote = new CommandWorkspaceRemote()
     const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote))
-    const missingWorkspace: WorkspaceError = {
-      code: 'workspace-not-found',
-      message: 'gone',
-      details: { workspaceId: wid('missing') },
-    }
-    const missingSession: WorkspaceError = {
-      code: 'session-not-found',
-      message: 'missing session',
-      details: { sessionId: sid('session') },
-    }
+    const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') })
+    const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') })
 
-    remote.create.mockResolvedValueOnce(remoteFailure({
-      code: 'workspace-invalid-path',
-      message: 'missing path',
-      details: { path: '/missing' },
-    }))
+    remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' })))
     const create = controller.create({ path: '/missing' })
     await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError)
-    await expect(create).rejects.toThrow('workspace-invalid-path: missing path')
+    await expect(create).rejects.toThrow('workspace/invalid-path: missing path')
 
     remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace))
-    await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone')
+    await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone')
     remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace))
-    await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone')
+    await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone')
     remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace))
-    await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone')
+    await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone')
     remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession))
-    await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session')
-    remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({
-      code: 'workspace-move-invalid',
-      message: 'invalid move',
-      details: { workspaceId: wid('missing'), sessionId: sid('session') },
-    }))
+    await expect(controller.archiveSession(sid('session')))
+      .rejects.toThrow('workspace session archive failed: session/not-found: missing session')
+    remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError(
+      'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') },
+    )))
     await expect(controller.insertSessionBefore(wid('missing'), sid('session')))
-      .rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move')
+      .rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move')
   })
 })

+ 21 - 22
packages/api/workspace-controller/tests/workspace-controller.host.spec.ts

@@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import Storage from '@deepseek-ai/dsh-storage'
 import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
-import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import WorkspaceController from '../src/index.ts'
@@ -14,6 +14,12 @@ import { WorkspaceFeed } from '../src/feed.ts'
 import type { WorkspaceFollowFrame } from '../src/types.ts'
 import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
 
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    'fixture/failure': {}
+  }
+}
+
 const roots: Context[] = []
 
 afterEach(async () => {
@@ -94,34 +100,29 @@ describe('WorkspaceController commands', () => {
     const second = await controller.create({ path: stageDir(root, 'second') })
 
     await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({
-      failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } },
+      code: 'workspace/invalid-path',
+      details: { path: join(root, 'missing') },
     })
     expect(existsSync(join(root, 'missing'))).toBe(false)
     await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: '  ' }))
-      .rejects.toMatchObject({ failure: { code: 'bad-request' } })
+      .rejects.toMatchObject({ code: 'gateway/bad-request' })
     await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' })
     await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' }))
-      .rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } })
+      .rejects.toMatchObject({ code: 'workspace/name-conflict' })
     await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId }))
-      .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+      .rejects.toMatchObject({ code: 'workspace/not-found' })
   })
 
   it('preserves Remote failures and propagates unexpected registry failures', async () => {
     const { controller, ctx, root } = await harness()
-    const remoteFailure = new TypertRemoteFailure({
-      code: 'fixture-failure',
-      message: 'already mapped',
-      details: {},
-    })
+    const remoteFailure = new RemoteError('fixture/failure', 'already mapped', {})
     const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath')
       .mockRejectedValueOnce(remoteFailure)
       .mockRejectedValueOnce('plain failure')
     await expect(controller.create({ path: stageDir(root, 'remote-failure') }))
       .rejects.toBe(remoteFailure)
     const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') })
-    await expect(plainFailure).rejects.toMatchObject({
-      failure: { code: 'workspace-invalid-path' },
-    })
+    await expect(plainFailure).rejects.toMatchObject({ code: 'workspace/invalid-path' })
     await expect(plainFailure).rejects.toThrow('plain failure')
     resolveByPath.mockRestore()
 
@@ -168,7 +169,7 @@ describe('WorkspaceController commands', () => {
     gate.resolve(undefined)
     await blocker
     await expect(deletion).resolves.toEqual({ deleted: true })
-    await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+    await expect(staleRename).rejects.toMatchObject({ code: 'workspace/not-found' })
   })
 
   it('reorders Workspaces and Sessions and archives only known Sessions', async () => {
@@ -182,7 +183,7 @@ describe('WorkspaceController commands', () => {
       workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId],
     })
     await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId }))
-      .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+      .rejects.toMatchObject({ code: 'workspace/not-found' })
 
     const session = ctx.sessions.create(SessionId('session-one'), {
       meta: { cwd: first.workspace.path },
@@ -197,26 +198,24 @@ describe('WorkspaceController commands', () => {
     await expect(controller.insertSessionBefore({
       workspaceId: first.workspace.workspaceId,
       sessionId: SessionId('missing-session'),
-    })).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } })
+    })).rejects.toMatchObject({ code: 'workspace/move-invalid' })
     await expect(controller.insertSessionBefore({
       workspaceId: first.workspace.workspaceId,
       sessionId: session.id,
       beforeSessionId: SessionId('missing-anchor'),
     })).rejects.toMatchObject({
-      failure: {
-        code: 'workspace-move-invalid',
-        details: { beforeSessionId: 'missing-anchor' },
-      },
+      code: 'workspace/move-invalid',
+      details: { beforeSessionId: 'missing-anchor' },
     })
     await expect(controller.insertSessionBefore({
       workspaceId: 'missing' as WorkspaceId,
       sessionId: session.id,
-    })).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+    })).rejects.toMatchObject({ code: 'workspace/not-found' })
 
     await expect(controller.archiveSession({ sessionId: session.id }))
       .resolves.toEqual({ archivedSessionIds: [session.id] })
     await expect(controller.archiveSession({ sessionId: SessionId('unknown') }))
-      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
+      .rejects.toMatchObject({ code: 'session/not-found' })
   })
 })
 

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

@@ -2,8 +2,6 @@
 
 export type {
   ClientRequest,
-  RpcError,
-  RpcErrorCode,
   RpcMessage,
   RpcRequest,
   RpcResponse,

+ 36 - 36
packages/client/connection/src/client/fixture.ts

@@ -1806,7 +1806,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       return {
         ok: false,
         error: {
-          code: 'settings-rejected',
+          code: 'settings/rejected',
           message: 'fixture: the minimal readiness settings descriptor is read-only',
           details: { ns },
         },
@@ -1816,7 +1816,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       return {
         ok: false,
         error: {
-          code: 'settings-rejected',
+          code: 'settings/rejected',
           message: 'fixture: the minimal readiness settings descriptor is read-only',
           details: { ns },
         },
@@ -1827,7 +1827,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       return {
         ok: false,
         error: {
-          code: 'settings-rejected',
+          code: 'settings/rejected',
           message: 'fixture: no settings namespaces are registered',
           details: { ns },
         },
@@ -1844,7 +1844,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         return {
           ok: false,
           error: {
-            code: 'agent-preset-read-only',
+            code: 'agent-preset/read-only',
             message: `agent preset "${agentPreset}" ships with the deployment`,
             details: { agentPreset, reason: 'it ships with the deployment' },
           },
@@ -2026,7 +2026,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   ): Promise<ConnectionRpcResult<never>> | undefined => {
     if (summaryOf(request.sessionId) !== undefined) return undefined
     return sessionErr({
-      code: 'session-not-found',
+      code: 'session/not-found',
       message: `no session ${request.sessionId}`,
       details: { sessionId: request.sessionId },
     })
@@ -2079,12 +2079,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
 
   const goalFailure = <T>(message: string): RpcResult<T> => ({
     ok: false,
-    error: { code: 'internal', message, details: {} },
+    error: { code: 'gateway/internal', message, details: {} },
   })
 
   const requireGoalSession = (id: SessionId): RpcResult<never> | undefined => (
     summaryOf(id) === undefined
-      ? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } }
+      ? { ok: false, error: { code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } } }
       : undefined
   )
 
@@ -2273,7 +2273,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       if (children === undefined) {
         return {
           ok: false,
-          error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } },
+          error: { code: 'directory-picker/unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } },
         }
       }
       return {
@@ -2292,13 +2292,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     createDirectory(parent: string, name: string): ConnectionRpcResult<string> {
       const children = childrenOf(parent)
       if (children === undefined) {
-        return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } }
+        return { ok: false, error: { code: 'directory-picker/create-failed', message: `missing parent ${parent}`, details: { path: parent } } }
       }
       // Same root special case as list's entry paths: a plain join under '/'
       // would mint '//name' and fork the tree's identity.
       const target = parent === '/' ? `/${name}` : `${parent}/${name}`
       if (children.includes(name)) {
-        return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } }
+        return { ok: false, error: { code: 'directory-picker/exists', message: `${target} already exists`, details: { path: target } } }
       }
       directoryTree.set(parent, [...children, name])
       directoryTree.set(target, [])
@@ -2428,7 +2428,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         return {
           ok: false,
           error: {
-            code: 'agent-preset-not-found',
+            code: 'agent-preset/not-found',
             message: `unknown agent preset "${agentPreset}"`,
             details: { agentPreset, available: [...fixturePresets.keys()] },
           },
@@ -2442,7 +2442,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         return {
           ok: false,
           error: {
-            code: 'agent-preset-not-found',
+            code: 'agent-preset/not-found',
             message: `unknown agent preset "${from}"`,
             details: { agentPreset: from, available: [...fixturePresets.keys()] },
           },
@@ -2452,7 +2452,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         return {
           ok: false,
           error: {
-            code: 'agent-preset-invalid',
+            code: 'agent-preset/invalid',
             message: `agent preset "${id}" already exists`,
             details: { agentPreset: id, reason: 'already exists' },
           },
@@ -2466,7 +2466,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         return {
           ok: false,
           error: {
-            code: 'agent-preset-read-only',
+            code: 'agent-preset/read-only',
             message: `agent preset "${id}" ships with the deployment`,
             details: { agentPreset: id, reason: 'it ships with the deployment' },
           },
@@ -2724,7 +2724,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     search: (request, signal) => {
       if (signal.aborted) {
         return sessionErr({
-          code: 'cancelled',
+          code: 'gateway/cancelled',
           message: 'fixture session search was aborted',
           details: {},
         })
@@ -2766,7 +2766,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         : workspaces.find(w => w.workspaceId === request.workspaceId)
       if (request.workspaceId !== undefined && workspace === undefined) {
         return sessionErr({
-          code: 'workspace-not-found',
+          code: 'workspace/not-found',
           message: `no workspace ${request.workspaceId}`,
           details: { workspaceId: request.workspaceId },
         })
@@ -2784,7 +2784,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         sessionId: SessionId,
         workspaceId: WorkspaceId,
       ): Promise<ConnectionRpcResult<{ sessionId: SessionId }>> => sessionErr({
-        code: 'workspace-attach-failed' as const,
+        code: 'session/workspace-attach-failed' as const,
         message: `fixture rejected Workspace attachment for ${sessionId}`,
         details: { sessionId, workspaceId },
       })
@@ -2793,7 +2793,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         if (existing !== undefined) {
           if (existing.cwd !== cwd) {
             return sessionErr({
-              code: 'session-conflict',
+              code: 'session/conflict',
               message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`,
               details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } },
             })
@@ -2834,7 +2834,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const normalized = title.trim().replace(/\s+/g, ' ')
       if (normalized.length === 0) {
         return sessionErr({
-          code: 'title-invalid',
+          code: 'session/title-invalid',
           message: 'session title must contain visible characters',
           details: { sessionId },
         })
@@ -2853,7 +2853,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const source = summaryOf(sessionId)
       if (source === undefined) {
         return sessionErr({
-          code: 'session-not-found',
+          code: 'session/not-found',
           message: `no session ${sessionId}`,
           details: { sessionId },
         })
@@ -2869,7 +2869,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
             : undefined)
       if (boundary === undefined) {
         return sessionErr({
-          code: 'fork-unavailable',
+          code: 'session/fork-unavailable',
           message: atSeq !== undefined && atSeq <= lastSeq
             ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}`
             : `session ${sessionId} has no completed turn`,
@@ -2923,18 +2923,18 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const { sessionId: id, mode, content } = request
       const summary = summaryOf(id)
       if (summary === undefined) {
-        return sessionErr({ code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
+        return sessionErr({ code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } })
       }
       if (options.rejectPrompt) {
         if (content.some(block => block.type === 'image')) {
           return sessionErr({
-            code: 'attachment-error',
+            code: 'session/attachment-invalid',
             message: 'fixture: image side exceeds the deployment limit',
             details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' },
           })
         }
         return sessionErr({
-          code: 'agent-busy',
+          code: 'session/agent-busy',
           message: 'fixture: prompt rejected before acceptance',
           details: { reason: 'fixture-prompt-rejection' },
         })
@@ -3029,7 +3029,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const stored = attachments.get(String(request.attachmentId))
       if (stored === undefined) {
         return sessionErr({
-          code: 'attachment-error',
+          code: 'session/attachment-invalid',
           message: 'fixture attachment missing',
           details: { reason: 'ATTACHMENT_NOT_FOUND' },
         })
@@ -3039,7 +3039,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         String(request.attachmentId),
       )) {
         return sessionErr({
-          code: 'attachment-error',
+          code: 'session/attachment-invalid',
           message: 'fixture attachment is not referenced by this session',
           details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
         })
@@ -3047,7 +3047,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       return sessionOk(stored)
     },
     updateQueue: request => sessionErr({
-      code: 'queue-item-not-found',
+      code: 'session/queue-item-not-found',
       message: 'fixture has no pending queue item',
       details: { itemId: request.itemId },
     }),
@@ -3226,7 +3226,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       return {
         ok: false,
         error: {
-          code: 'invocation-unavailable',
+          code: 'gateway/invocation-unavailable',
           message: 'fixture Remote event result identifies no active event stream',
           details: {},
         },
@@ -3269,7 +3269,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId)
       if (workspace === undefined) {
         return sessionErr({
-          code: 'workspace-not-found',
+          code: 'workspace/not-found',
           message: `no workspace ${request.workspaceId}`,
           details: { workspaceId: request.workspaceId },
         })
@@ -3277,7 +3277,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const title = request.title.trim()
       if (title === '') {
         return sessionErr({
-          code: 'bad-request',
+          code: 'gateway/bad-request',
           message: 'Workspace rename requires a non-blank title',
           details: {},
         })
@@ -3285,7 +3285,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       if (title !== workspace.title) {
         if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) {
           return sessionErr({
-            code: 'workspace-name-conflict',
+            code: 'workspace/name-conflict',
             message: `workspace name '${title}' is already in use`,
             details: { name: title },
           })
@@ -3300,7 +3300,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId)
       if (index === -1) {
         return sessionErr({
-          code: 'workspace-not-found',
+          code: 'workspace/not-found',
           message: `no workspace ${request.workspaceId}`,
           details: { workspaceId: request.workspaceId },
         })
@@ -3321,7 +3321,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           : undefined
       if (missing !== undefined) {
         return sessionErr({
-          code: 'workspace-not-found',
+          code: 'workspace/not-found',
           message: `no workspace ${missing}`,
           details: { workspaceId: missing },
         })
@@ -3348,7 +3348,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId)
       if (workspace === undefined) {
         return sessionErr({
-          code: 'workspace-not-found',
+          code: 'workspace/not-found',
           message: `no workspace ${request.workspaceId}`,
           details: { workspaceId: request.workspaceId },
         })
@@ -3356,7 +3356,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       if (!workspace.sessionIds.includes(request.sessionId)
         || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) {
         return sessionErr({
-          code: 'workspace-move-invalid',
+          code: 'workspace/move-invalid',
           message: `session or anchor is not accounted by workspace ${request.workspaceId}`,
           details: {
             workspaceId: request.workspaceId,
@@ -3378,7 +3378,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     archiveSession: (request) => {
       if (summaryOf(request.sessionId) === undefined) {
         return sessionErr({
-          code: 'session-not-found',
+          code: 'session/not-found',
           message: `no session ${request.sessionId}`,
           details: { sessionId: request.sessionId },
         })

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

@@ -29,7 +29,7 @@ declare module '@deepseek-ai/cordis' {
 // ---- Browser-safe protocol and shared value re-exports ----
 export type {
   MessageId,
-  RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
+  RpcRequest, RpcResponse, RpcResult,
   ClientRequest, ServerResponse, RpcMessage,
   SessionId, SessionEvent, ContentBlock, StreamChunk,
 } from './api.ts'

+ 2 - 2
packages/client/connection/src/rpc-host.ts

@@ -230,7 +230,7 @@ function rpcFetchHandler(
       const message: ClientRequest = envelope.data
       if (message.method !== endpoint) {
         return errorResponse(message.rpcId, {
-          code: 'bad-request',
+          code: 'gateway/bad-request',
           message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
           details: { issues: [] },
         })
@@ -250,7 +250,7 @@ function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Resp
   const rawId = (body as { rpcId?: unknown } | null)?.rpcId
   const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
   return errorResponse(rpcId, {
-    code: 'bad-request',
+    code: 'gateway/bad-request',
     message: 'invalid client-request message',
     details: { issues },
   })

+ 1 - 28
packages/client/connection/src/rpc.ts

@@ -1,7 +1,6 @@
 /** Generic unary RPC contracts shared by the Host and Client Connection halves. */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 /** Correlation id minted by a caller and echoed by the Connection response. */
 export type RpcId = Branded<'rpc-id'>
@@ -27,32 +26,6 @@ export type ConnectionRpcResult<T> =
   | { readonly ok: true; readonly value: T }
   | { readonly ok: false; readonly error: ConnectionRpcFailure }
 
-/** Typed failure details used by Client Session adapters. */
-export interface RpcErrorDetailsMap {
-  'bad-request': { issues: object[] }
-  'cancelled': {}
-  'session-not-found': { sessionId: SessionId }
-  'invalid-time-zone': { value: string }
-  'agent-preset-read-only': { agentPreset: string; reason: string }
-  'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
-  'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
-  'agent-preset-invalid': { agentPreset: string; reason: string }
-  'agent-busy': { reason: string }
-  'internal': {}
-}
-
-/** Error codes used by Client Session adapters. */
-export type RpcErrorCode = keyof RpcErrorDetailsMap
-
-/** Typed failure used by Client Session adapters. */
-export type RpcError = {
-  [Code in RpcErrorCode]: {
-    readonly code: Code
-    readonly message: string
-    readonly details: RpcErrorDetailsMap[Code]
-  }
-}[RpcErrorCode]
-
 /** Historical short name for a generic Connection result. */
 export type RpcResult<T> = ConnectionRpcResult<T>
 
@@ -65,7 +38,7 @@ export function transportError<T>(error: unknown): RpcResult<T> {
   return {
     ok: false,
     error: {
-      code: 'internal',
+      code: 'gateway/internal',
       message: error instanceof Error ? error.message : String(error),
       details: {},
     },

+ 1 - 1
packages/client/connection/tests/api-helpers.client.spec.ts

@@ -9,7 +9,7 @@ import { RpcId, resultOf, transportError } from '../src/client/api.ts'
 
 describe('transportError', () => {
   it('folds an Error to internal keeping the message, and stringifies non-Errors', () => {
-    expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'internal', message: '线断了', details: {} } })
+    expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'gateway/internal', message: '线断了', details: {} } })
     expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } })
   })
 })

+ 3 - 3
packages/client/connection/tests/fixture-commands.client.spec.ts

@@ -36,7 +36,7 @@ describe('createFixtureApi commands/skills', () => {
   it('rejects a catalog request for an unknown session', async () => {
     const { rpc } = createFixtureFaces()
     const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } })
-    expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
+    expect(result).toMatchObject({ ok: false, error: { code: 'session/not-found' } })
   })
 
   it('executes a known command line: pure admission plus a followed lifecycle pair', async () => {
@@ -76,7 +76,7 @@ describe('createFixtureApi commands/skills', () => {
     const missing = await rpc.call('/api', 'commands/execute', {
       args: { agentId: sid('fx-nope'), line: '/goal ship' },
     })
-    expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
+    expect(missing).toMatchObject({ ok: false, error: { code: 'session/not-found' } })
   })
 
   it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => {
@@ -165,7 +165,7 @@ describe('createFixtureApi commands/skills', () => {
     const missingSession = await rpc.call('/api', 'skills/list', {
       args: { request: { sessionId: sid('fx-nope') } },
     })
-    expect(missingSession).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
+    expect(missingSession).toMatchObject({ ok: false, error: { code: 'session/not-found' } })
   })
 })
 

+ 23 - 23
packages/client/connection/tests/fixture.client.spec.ts

@@ -664,7 +664,7 @@ describe('createFixtureApi', () => {
     const aborted = new AbortController()
     aborted.abort()
     await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
-      .resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
+      .resolves.toMatchObject({ result: { ok: false, error: { code: 'gateway/cancelled' } } })
   })
 
   it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
@@ -778,7 +778,7 @@ describe('createFixtureApi', () => {
     ]) {
       expect(result).toMatchObject({
         ok: false,
-        error: { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' },
+        error: { code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' },
       })
     }
 
@@ -866,9 +866,9 @@ describe('createFixtureApi', () => {
       for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame)
     })()
     await new Promise(resolve => setTimeout(resolve, 10))
-    // Unknown session → session-not-found with the id echoed in details.
+    // Unknown session → session/not-found with the id echoed in details.
     const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'ghost' } } })
     // Real prompt: replay starts (running flips true), cancel freezes it.
     const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
     expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
@@ -1042,7 +1042,7 @@ describe('createFixtureApi', () => {
       clientId,
       eventId: question.eventId,
       outcome: { kind: 'result', value: { answers: {} } },
-    })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } })
+    })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } })
     const remaining = await readResidentRemoteEvents(api, 1)
     expect(remaining.map(frame => frame.event)).toEqual(['approval/request'])
 
@@ -1097,7 +1097,7 @@ describe('createFixtureApi', () => {
       clientId: await stream.clientId,
       eventId: approval.eventId,
       outcome: { kind: 'next' },
-    })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } })
+    })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } })
     const remaining = await readResidentRemoteEvents(api, 1)
     expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request'])
   })
@@ -1172,11 +1172,11 @@ describe('createFixtureApi', () => {
     await new Promise(resolve => setTimeout(resolve, 10))
     const wsid = 'fx-ws-fixture' as WorkspaceId
     const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } })
 
     await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' }))
     const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
-    expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
+    expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace/name-conflict', details: { name: 'occupied' } } })
 
     const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' }))
     if (!noop.result.ok) throw new Error('no-op rename failed')
@@ -1210,10 +1210,10 @@ describe('createFixtureApi', () => {
     await new Promise(resolve => setTimeout(resolve, 10))
 
     const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'fx-void' } } })
 
     const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: '   ' }))
-    expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
+    expect(blank.result).toMatchObject({ ok: false, error: { code: 'session/title-invalid', details: { sessionId: 'fx-alpha' } } })
 
     const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: '  重命名  ' }))
     if (!renamed.result.ok) throw new Error('rename failed')
@@ -1247,11 +1247,11 @@ describe('createFixtureApi', () => {
     const api = createFixtureApi()
     const wsid = 'fx-ws-fixture' as WorkspaceId
     const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } })
     const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') }))
-    expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } })
+    expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { sessionId: 'fx-ghost' } } })
     const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') }))
-    expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
+    expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
 
     const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') }))
     if (!moved.result.ok) throw new Error('move failed')
@@ -1276,7 +1276,7 @@ describe('createFixtureApi', () => {
     )
     await new Promise(resolve => setTimeout(resolve, 10))
     const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } })
     const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
     expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
     const frames = await consuming
@@ -1309,7 +1309,7 @@ describe('createFixtureApi', () => {
     )
     await new Promise(resolve => setTimeout(resolve, 10))
     const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } })
     const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
     if (!created.result.ok) throw new Error('create failed')
     const id = created.result.value.sessionId
@@ -1384,7 +1384,7 @@ describe('createFixtureApi', () => {
     const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
     expect(conflict.result).toMatchObject({
       ok: false,
-      error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
+      error: { code: 'session/conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
     })
   })
 
@@ -1416,7 +1416,7 @@ describe('createFixtureApi', () => {
     expect(conflict.result).toEqual({
       ok: false,
       error: {
-        code: 'session-conflict',
+        code: 'session/conflict',
         message: `session ${existing.sessionId} already uses no cwd`,
         details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
       },
@@ -1432,7 +1432,7 @@ describe('createFixtureApi', () => {
     }))
     expect(created.result).toMatchObject({
       ok: false,
-      error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
+      error: { code: 'session/workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
     })
     const listed = await api.sessions.list(req({}))
     const workspaces = await readWorkspaceBaseline(api.workspaceRemote)
@@ -1444,7 +1444,7 @@ describe('createFixtureApi', () => {
       workspaceId: 'fx-ws-fixture' as WorkspaceId,
       sessionId,
     }))
-    expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
+    expect(retried.result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
     const afterRetry = await api.sessions.list(req({}))
     if (!afterRetry.result.ok) throw new Error('list failed')
     expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
@@ -1475,7 +1475,7 @@ describe('createFixtureApi', () => {
       mode: 'queue' as const,
       content: [{ type: 'text' as const, text: 'keep me' }],
     }))
-    expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
+    expect(prompt.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
     const imagePrompt = await rejecting.sessions.prompt(req({
       sessionId: real.result.value.sessionId,
       mode: 'queue' as const,
@@ -1483,7 +1483,7 @@ describe('createFixtureApi', () => {
     }))
     expect(imagePrompt.result).toMatchObject({
       ok: false,
-      error: { code: 'attachment-error', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } },
+      error: { code: 'session/attachment-invalid', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } },
     })
   })
 
@@ -1722,7 +1722,7 @@ describe('fixture Connection RPC', () => {
       mode: 'queue',
       content: [{ type: 'text', text: 'retain' }],
     })
-    expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
+    expect(rejected.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
   })
 
   it('maps attach-failure and dropped-response query scenarios', async () => {
@@ -1734,7 +1734,7 @@ describe('fixture Connection RPC', () => {
     })
     expect(partialResult.result).toMatchObject({
       ok: false,
-      error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
+      error: { code: 'session/workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
     })
 
     vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })

+ 2 - 2
packages/client/connection/tests/node-half.host.spec.ts

@@ -403,7 +403,7 @@ describe('connection node half', () => {
     }), methodMismatch.response)
     expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
       rpcId: 'rpc-bad',
-      result: { ok: false, error: { code: 'bad-request' } },
+      result: { ok: false, error: { code: 'gateway/bad-request' } },
     })
 
     for (const [request, status] of [
@@ -428,7 +428,7 @@ describe('connection node half', () => {
       await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response)
       expect(JSON.parse(String(response.state.body))).toMatchObject({
         rpcId,
-        result: { ok: false, error: { code: 'bad-request' } },
+        result: { ok: false, error: { code: 'gateway/bad-request' } },
       })
     }
 

+ 2 - 2
packages/client/connection/tests/rpc-schema.host.spec.ts

@@ -20,11 +20,11 @@ describe('Connection RPC schema', () => {
   it('folds transport exceptions into an internal failure', () => {
     expect(transportError(new Error('wire down'))).toEqual({
       ok: false,
-      error: { code: 'internal', message: 'wire down', details: {} },
+      error: { code: 'gateway/internal', message: 'wire down', details: {} },
     })
     expect(transportError('raw')).toMatchObject({
       ok: false,
-      error: { code: 'internal', message: 'raw' },
+      error: { code: 'gateway/internal', message: 'raw' },
     })
   })
 

+ 1 - 1
packages/client/locale/src/client/index.ts

@@ -528,7 +528,7 @@ function detectBrowserLocale(locales: readonly LocaleDefinition[]): LocaleId | u
 }
 
 /** Required services: slot registration plus the settings transport. */
-export const inject = ['slots', 'connection', 'remote', 'settingsScope']
+export const inject = ['slots', 'remote', 'settingsScope']
 
 /**
  * Client plugin body: provide the locale service with base dictionaries and

+ 1 - 2
packages/client/locale/tests/apply.client.spec.ts

@@ -38,7 +38,6 @@ async function bench() {
     revision += 1
     return { ok: true as const, value: namespace() }
   })
-  ctx.provide('connection', { api: {}, isLoopback: true } as never)
   const events = new TestRemote(ctx, { settings: { describe, mutate } })
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return {
@@ -72,7 +71,7 @@ describe('locale apply', () => {
   // setLocale/Host preference instead of leaning on a dead browser pin.
 
   it('declares the slot service', () => {
-    expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
+    expect(inject).toEqual(['slots', 'remote', 'settingsScope'])
   })
 
   it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {

+ 0 - 1
packages/client/locale/tests/document-language.client.spec.ts

@@ -40,7 +40,6 @@ async function bench(preference?: string) {
     revision += 1
     return { ok: true as const, value: namespace() }
   })
-  ctx.provide('connection', { api: {}, isLoopback: true } as never)
   // The settings transport and the forwarded-event port the plugin injects.
   new TestRemote(ctx, { settings: { describe: describeRpc, mutate } })
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()

+ 1 - 2
packages/client/locale/tests/invariant.client.spec.ts

@@ -21,10 +21,9 @@ describe('invariant companion', () => {
 
   it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
     // The feature registers its own Language settings row, hence the slots edge.
-    expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
+    expect(inject).toEqual(['slots', 'remote', 'settingsScope'])
     const ctx = new Context()
     new SlotRegistry(ctx)
-    ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
     // The settings row's transport and the forwarded-event port.
     ctx.provide('remote', { $on: () => () => {} } as never)
     ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)

+ 3 - 4
packages/client/ui-agent-preset/src/client/index.ts

@@ -58,12 +58,11 @@ export const inject = [
  * @param ctx - the browser plugin context.
  */
 export function apply(ctx: ClientContext): void {
-  const settingsWire = { settings: ctx.remote.settings }
-  const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe())
+  const controller = new AgentPresetSettingsController(ctx, ctx.settingsScope.describe())
   // One roster, four surfaces. The chip is registered in a later scope, so it
   // subscribes here rather than being reached from this one.
   const rosterReaders = new Set<() => void>()
-  const section = new AgentPresetSectionController(ctx.remote, () => {
+  const section = new AgentPresetSectionController(ctx, () => {
     void controller.load()
     for (const read of rosterReaders) read()
   })
@@ -105,7 +104,7 @@ export function apply(ctx: ClientContext): void {
   // The new-session chip and the header label: one controller, because the
   // staged choice belongs to the flow rather than to any one session.
   ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
-    const seat = new AgentPresetSeatController(scope.remote, () => {
+    const seat = new AgentPresetSeatController(scope, () => {
       const state = scope.sessions.list.getSnapshot()
       return state.current === undefined ? undefined : state.byId[state.current]
     })

+ 21 - 28
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -10,11 +10,13 @@
  * deployment default again, matching the workspace picker beside it.
  */
 
-import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+// Type-only: pulls the ctx.remote merge into this program.
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
-import { messageOf, presetOptions, readRoster } from './settings-store.ts'
+import { presetOptions, readRoster } from './settings-store.ts'
 import type { AgentPresetOption } from './settings-store.ts'
 
 /** Hero-chip snapshot. */
@@ -53,7 +55,7 @@ export class AgentPresetSeatController {
   private staged: string | undefined
 
   constructor(
-    private readonly remote: Pick<ClientRemote, 'agentPresets'>,
+    private readonly ctx: ClientContext,
     /** The session the hero is about to hand over to, when there is one. */
     private readonly currentSession: () => Pick<
       SessionSummary,
@@ -70,7 +72,7 @@ export class AgentPresetSeatController {
   * @returns once the snapshot reflects the host.
   */
   async load(): Promise<void> {
-    const roster = await readRoster(this.remote)
+    const roster = await readRoster(this.ctx)
     if (!roster.ok) {
       this.set({ error: roster.error })
       return
@@ -155,35 +157,26 @@ export class AgentPresetSeatController {
       return
     }
     this.set({ busy: true, error: null })
-    try {
-      const result = await this.remote.agentPresets.select(session.id, staged)
-      this.staged = undefined
-      if (!result.ok) {
-        const { error } = result
-        this.set({
-          busy: false,
-          // A refusal carries its cause twice: `message` wraps it in the
-          // roster's own frame, which names the preset the surface reporting
-          // this already names, and a `reason` detail holds the same cause
-          // without it. Read by the detail rather than by the code, because
-          // every refusal that has a cause to give names it the same way.
-          error: 'reason' in error.details && typeof error.details.reason === 'string'
-            ? error.details.reason
-            : error.message,
-          current: presetOf(session) ?? '',
-        })
-        return
-      }
-      // Consumed: the next new session opens on the deployment default again.
-      this.set({ busy: false, current: result.value })
-    } catch (error) {
-      this.staged = undefined
+    const result = await this.ctx.remote.agentPresets.select(session.id, staged)
+    this.staged = undefined
+    if (!result.ok) {
+      const { error } = result
       this.set({
         busy: false,
-        error: messageOf(error),
+        // A refusal carries its cause twice: `message` wraps it in the
+        // roster's own frame, which names the preset the surface reporting
+        // this already names, and a `reason` detail holds the same cause
+        // without it. Read by the detail rather than by the code, because
+        // every refusal that has a cause to give names it the same way.
+        error: 'reason' in error.details && typeof error.details.reason === 'string'
+          ? error.details.reason
+          : error.message,
         current: presetOf(session) ?? '',
       })
+      return
     }
+    // Consumed: the next new session opens on the deployment default again.
+    this.set({ busy: false, current: result.value })
   }
 }
 

+ 46 - 60
packages/client/ui-agent-preset/src/client/section-store.ts

@@ -14,9 +14,11 @@
  * more than the row it targeted.
  */
 
-import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+// Type-only: pulls the ctx.remote merge into this program.
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
+import { beginRosterRead, writeDefaultPreset } from './settings-store.ts'
 
 /** Ids a preset directory may be named, mirroring the host's own rule. */
 const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
@@ -133,7 +135,7 @@ export class AgentPresetSectionController {
   readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
 
   constructor(
-    private readonly remote: Pick<ClientRemote, 'agentPresets' | 'settings'>,
+    private readonly ctx: ClientContext,
     /**
      * Called after this page changes the roster DIRECTORY, so the other
      * surfaces reading the same roster re-read it. A settings field moving is
@@ -167,13 +169,13 @@ export class AgentPresetSectionController {
     // Issued together: one round trip decides the page, and a load that waited
     // for them in turn would hold the section in `loading` twice as long,
     // where a concurrent reload silently returns instead of refreshing.
-    const opener = this.remote.settings.canOpenAgentPresetDirectory()
-    const roster = await beginRosterRead(this.remote, this.store)
+    const opener = this.ctx.remote.settings.canOpenAgentPresetDirectory()
+    const roster = await beginRosterRead(this.ctx, this.store)
     // A refused describe leaves the reveal-the-path path, which needs no opener.
-    const described = await opener.catch(() => undefined)
+    const described = await opener
     if (roster === undefined) return
     const { presets, authorable } = roster
-    const hasDocument = described?.ok === true && described.value
+    const hasDocument = described.ok && described.value
     if (presets.length === 0) {
       // Nothing to manage leaves nothing to keep a dialog open over.
       this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
@@ -201,17 +203,13 @@ export class AgentPresetSectionController {
    */
   async view(id: string): Promise<void> {
     this.set({ error: null })
-    try {
-      const result = await this.remote.agentPresets.read(id)
-      if (!result.ok) {
-        this.set({ error: result.error.message })
-        return
-      }
-      const { name, content } = result.value
-      this.set({ view: { id, title: name ?? id, content } })
-    } catch (error) {
-      this.set({ error: messageOf(error) })
+    const result = await this.ctx.remote.agentPresets.read(id)
+    if (!result.ok) {
+      this.set({ error: result.error.message })
+      return
     }
+    const { name, content } = result.value
+    this.set({ view: { id, title: name ?? id, content } })
   }
 
   /** Close the read-only viewer. */
@@ -263,27 +261,23 @@ export class AgentPresetSectionController {
     if (draft === null || draft.saving) return
     if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
     this.patchCopy({ saving: true, error: null })
-    try {
-      const name = draft.name.trim()
-      // Every declared parameter is passed even when optional: the Remote face
-      // checks arity against the declaration and rejects a short call. An
-      // empty display name goes as `undefined` — absent rather than empty, so
-      // the host falls back to the id instead of labelling the row with ''.
-      const result = await this.remote.agentPresets.copy(
-        draft.from, draft.id, name === '' ? undefined : name)
-      if (!result.ok) {
-        this.patchCopy({ saving: false, error: result.error.message })
-        return
-      }
-      this.set({ copy: null })
-      await this.load()
-      this.rosterChanged()
-      // A preset is its files from here on (the dialog collected nothing
-      // else), so landing in them is the completion, not a follow-up.
-      await this.openLocation(draft.id)
-    } catch (error) {
-      this.patchCopy({ saving: false, error: messageOf(error) })
+    const name = draft.name.trim()
+    // Every declared parameter is passed even when optional: the Remote face
+    // checks arity against the declaration and rejects a short call. An
+    // empty display name goes as `undefined` — absent rather than empty, so
+    // the host falls back to the id instead of labelling the row with ''.
+    const result = await this.ctx.remote.agentPresets.copy(
+      draft.from, draft.id, name === '' ? undefined : name)
+    if (!result.ok) {
+      this.patchCopy({ saving: false, error: result.error.message })
+      return
     }
+    this.set({ copy: null })
+    await this.load()
+    this.rosterChanged()
+    // A preset is its files from here on (the dialog collected nothing
+    // else), so landing in them is the completion, not a follow-up.
+    await this.openLocation(draft.id)
   }
 
   /**
@@ -293,18 +287,14 @@ export class AgentPresetSectionController {
    * @returns once the host answered and the page reflects it.
    */
   async openLocation(id: string): Promise<void> {
-    try {
-      const result = await this.remote.settings.openAgentPresetDirectory(id)
-      if (!result.ok) {
-        this.set({ error: result.error.message })
-        return
-      }
-      if (result.value.opened) return
-      const { path } = result.value
-      this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
-    } catch (error) {
-      this.set({ error: messageOf(error) })
+    const result = await this.ctx.remote.settings.openAgentPresetDirectory(id)
+    if (!result.ok) {
+      this.set({ error: result.error.message })
+      return
     }
+    if (result.value.opened) return
+    const { path } = result.value
+    this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
   }
 
   /**
@@ -327,18 +317,14 @@ export class AgentPresetSectionController {
     const { pendingDelete, deleting } = this.store.getSnapshot()
     if (pendingDelete === null || deleting) return
     this.set({ deleting: true, error: null })
-    try {
-      const result = await this.remote.agentPresets.deletePreset(pendingDelete)
-      if (!result.ok) {
-        this.set({ deleting: false, pendingDelete: null, error: result.error.message })
-        return
-      }
-      this.set({ deleting: false, pendingDelete: null })
-      await this.load()
-      this.rosterChanged()
-    } catch (error) {
-      this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
+    const result = await this.ctx.remote.agentPresets.deletePreset(pendingDelete)
+    if (!result.ok) {
+      this.set({ deleting: false, pendingDelete: null, error: result.error.message })
+      return
     }
+    this.set({ deleting: false, pendingDelete: null })
+    await this.load()
+    this.rosterChanged()
   }
 
   /**
@@ -348,7 +334,7 @@ export class AgentPresetSectionController {
    * @returns once the write settled and the roster was re-read.
    */
   async makeDefault(id: string): Promise<void> {
-    const failure = await writeDefaultPreset(this.remote, id)
+    const failure = await writeDefaultPreset(this.ctx, id)
     if (failure !== undefined) {
       this.set({ error: failure })
       return

+ 28 - 55
packages/client/ui-agent-preset/src/client/settings-store.ts

@@ -7,51 +7,35 @@
  * namespace's `default` field, which is what the host resolves at creation.
  */
 
-import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+// Type-only: pulls the ctx.remote merge into this program.
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types'
-import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
+import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
 
 /** The agent-preset settings namespace on the host wire. */
 export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
 
-/**
- * Human text for a rejected wire call. A transport failure rejects with an
- * Error; a host or a runtime can reject with anything, and the surface still
- * has to say something.
- * @param error - the rejection value.
- * @returns the message to show.
- */
-export function messageOf(error: unknown): string {
-  return error instanceof Error ? error.message : String(error)
-}
-
 /**
  * Persist one preset as the default for sessions created later.
  *
  * The default is a settings field rather than a preset property, so both the
  * General row and the management section write it here — one home for which
  * namespace and field the host resolves at session creation.
- * @param api - the settings wire face.
+ * @param ctx - the browser plugin context carrying the Remote namespaces.
  * @param id - the preset to make default.
  * @returns the failure message, or undefined once the write landed.
  */
 export async function writeDefaultPreset(
-  api: SettingsWireFace,
+  ctx: ClientContext,
   id: string,
 ): Promise<string | undefined> {
-  let response
-  try {
-    response = await api.settings.update(
-      AGENT_PRESET_SETTINGS_NS,
-      { default: id },
-      undefined,
-    )
-  } catch (error) {
-    // The transport rejected rather than answering; the caller must be able to
-    // say so instead of the row silently snapping back.
-    return messageOf(error)
-  }
+  const response = await ctx.remote.settings.update(
+    AGENT_PRESET_SETTINGS_NS,
+    { default: id },
+    undefined,
+  )
   return response.ok ? undefined : response.error.message
 }
 
@@ -76,27 +60,18 @@ export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; e
 const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false }
 
 /**
- * Read the roster, folding both refusal shapes into one message.
- *
- * The wire refuses in two ways — the transport rejects, or it answers an
- * `ok: false` envelope — and every surface treats them identically. Folding
- * them here keeps each store's `load` about what it does with a roster rather
- * than about how the call can fail.
- * @param remote - the agent-preset Remote namespace.
+ * Read the roster, turning a refusal into the message every surface shows.
+ * @param ctx - the browser plugin context carrying the Remote namespaces.
  * @returns the roster, or the message to show in its place.
  */
-export async function readRoster(remote: Pick<ClientRemote, 'agentPresets'>): Promise<RosterRead> {
-  try {
-    const result = await remote.agentPresets.list()
-    if (result.ok) return { ok: true, value: result.value }
-    // Agent presets are optional: without that service every session uses the
-    // Host composition, so callers receive the same empty roster as a mounted
-    // service with no configured roots.
-    if (result.error.code === 'invocation-unavailable') return { ok: true, value: EMPTY_ROSTER }
-    return { ok: false, error: result.error.message }
-  } catch (error) {
-    return { ok: false, error: messageOf(error) }
-  }
+export async function readRoster(ctx: ClientContext): Promise<RosterRead> {
+  const result = await ctx.remote.agentPresets.list()
+  if (result.ok) return { ok: true, value: result.value }
+  // Agent presets are optional: without that service every session uses the
+  // Host composition, so callers receive the same empty roster as a mounted
+  // service with no configured roots.
+  if (result.error.code === 'gateway/invocation-unavailable') return { ok: true, value: EMPTY_ROSTER }
+  return { ok: false, error: result.error.message }
 }
 
 /**
@@ -106,18 +81,18 @@ export async function readRoster(remote: Pick<ClientRemote, 'agentPresets'>): Pr
  * A surface that gets `undefined` returns without touching its snapshot
  * further — either another read owns it, or this one already wrote the
  * failure. What differs between surfaces starts after this.
- * @param remote - the agent-preset Remote namespace.
+ * @param ctx - the browser plugin context carrying the Remote namespaces.
  * @param store - the surface's own snapshot store.
  * @returns the roster, or undefined when the caller should return.
  */
 export async function beginRosterRead<S extends { status: string; error: string | null }>(
-  remote: Pick<ClientRemote, 'agentPresets'>,
+  ctx: ClientContext,
   store: SnapshotStore<S>,
 ): Promise<AgentPresetRoster | undefined> {
   const before = store.getSnapshot()
   if (before.status === 'loading') return undefined
   store.set({ ...before, status: 'loading', error: null })
-  const roster = await readRoster(remote)
+  const roster = await readRoster(ctx)
   if (roster.ok) return roster.value
   store.set({ ...store.getSnapshot(), status: 'error', error: roster.error })
   return undefined
@@ -180,13 +155,11 @@ export class AgentPresetSettingsController {
   readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
 
   /**
-   * @param api - the settings wire face (the default write).
-   * @param remote - the agent-preset Remote namespace (the roster read).
+   * @param ctx - the browser plugin context (the roster read and the default write).
    * @param describeFace - the shared mirror's describe face (writability source).
    */
   constructor(
-    private readonly api: SettingsWireFace,
-    private readonly remote: Pick<ClientRemote, 'agentPresets'>,
+    private readonly ctx: ClientContext,
     private readonly describeFace: SettingsDescribeFace,
   ) {}
 
@@ -201,7 +174,7 @@ export class AgentPresetSettingsController {
    * @returns once the snapshot reflects the host.
    */
   async load(): Promise<void> {
-    const roster = await beginRosterRead(this.remote, this.store)
+    const roster = await beginRosterRead(this.ctx, this.store)
     if (roster === undefined) return
     const { presets } = roster
     const [first] = presets
@@ -236,7 +209,7 @@ export class AgentPresetSettingsController {
     const before = this.store.getSnapshot()
     if (before.status === 'saving' || id === before.currentValue) return
     this.set({ status: 'saving', error: null, currentValue: id })
-    const failure = await writeDefaultPreset(this.api, id)
+    const failure = await writeDefaultPreset(this.ctx, id)
     if (failure !== undefined) {
       this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
       return

+ 22 - 29
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
@@ -115,7 +115,6 @@ async function bench() {
   }
   ctx.provide('remote.agentPresets', agentPresets as never)
   Object.assign(remote, { agentPresets })
-  ctx.provide('connection', { isLoopback: true } as never)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote }
 }
@@ -581,8 +580,10 @@ describe('AgentPresetSeatController reconciliation', () => {
   it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => {
     const state: { current?: { id: SessionId; blank: boolean } } = {}
     const controller = new AgentPresetSeatController({
-      agentPresets: {
-        list: () => Promise.resolve(ROSTER_ONE),
+      remote: {
+        agentPresets: {
+          list: () => Promise.resolve(ROSTER_ONE),
+        },
       },
     } as never, () => state.current)
 
@@ -595,43 +596,35 @@ describe('AgentPresetSeatController reconciliation', () => {
     expect(controller.store.getSnapshot().current).toBe('')
   })
 
-  it.each([
-    {
-      name: 'RPC rejection',
-      select: () => Promise.resolve({
-        ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} },
-      }),
-      message: 'selection rejected',
-    },
-    {
-      name: 'transport failure',
-      select: () => Promise.reject(new Error('transport failed')),
-      message: 'transport failed',
-    },
-  ])('restores an empty current value after $name for an uncomposed Session', async ({ select, message }) => {
+  it('restores an empty current value after a refused switch for an uncomposed Session', async () => {
+    const select = () => Promise.resolve({
+      ok: false as const, error: new RemoteError('gateway/internal', 'selection rejected', {}),
+    })
     const controller = new AgentPresetSeatController({
-      agentPresets: { select },
+      remote: { agentPresets: { select } },
     } as never, () => ({ id: SessionId('uncomposed'), blank: true }))
 
     await controller.select('minimal')
 
     expect(controller.store.getSnapshot()).toMatchObject({
-      busy: false, current: '', error: message,
+      busy: false, current: '', error: 'selection rejected',
     })
   })
 
   it('keeps the bare cause of a mount failure, not the frame that names the preset again', async () => {
     const reason = 'failed to import loader entry ctx (@deepseek-ai/dsh-gone): Cannot find package'
     const controller = new AgentPresetSeatController({
-      agentPresets: {
-        select: () => Promise.resolve({
-          ok: false as const,
-          error: {
-            code: 'agent-preset-invalid',
-            message: `agent-presets: preset "broken" failed to mount: ${reason}`,
-            details: { agentPreset: 'broken', reason },
-          },
-        }),
+      remote: {
+        agentPresets: {
+          select: () => Promise.resolve({
+            ok: false as const,
+            error: new RemoteError(
+              'agent-preset/invalid',
+              `agent-presets: preset "broken" failed to mount: ${reason}`,
+              { agentPreset: 'broken', reason },
+            ),
+          }),
+        },
       },
     } as never, () => ({ id: SessionId('uncomposed'), blank: true }))
 

+ 86 - 149
packages/client/ui-agent-preset/tests/section-store.client.spec.ts

@@ -7,7 +7,8 @@
  */
 
 import { describe, expect, it } from 'vitest'
-import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
 import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
 
@@ -29,27 +30,19 @@ interface FakeOptions {
   failRemove?: string
   /** Reject `settings.update` with this message. */
   failSettings?: string
-  /** Throw from `list` rather than answering, as a dead transport does. */
-  throwList?: boolean
-  /** Throw from `read`, as a dead transport does. */
-  throwRead?: boolean
-  /** Throw from `copy`, as a dead transport does. */
-  throwCopy?: boolean
-  /** Throw from `openDocument`, as a dead transport does. */
-  throwOpen?: boolean
   /** Whether the deployment configures a writable root. */
   authorable?: boolean
   /** Whether the host can open a preset directory on a desktop. */
   hasDocument?: boolean
-  /** Reject the opener capability read, as a dead transport does. */
-  throwCapability?: boolean
+  /** Refuse the opener capability read. */
+  failCapability?: string
   /** Hold `remove` until this resolves, to observe the in-flight state. */
   holdRemove?: Promise<void>
 }
 
 const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
 const remoteFail = (message: string) =>
-  Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } })
+  Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', message, {}) })
 
 /**
  * The Remote namespace over an in-memory preset store: copies land, so the
@@ -57,95 +50,93 @@ const remoteFail = (message: string) =>
  * @param presets - the starting compositions by id.
  * @param defaultId - the preset a session with no choice gets.
  * @param options - failure injection and call recording.
- * @returns the fake Remote namespace.
+ * @returns the fake plugin context carrying the Remote namespaces.
  */
-function fakeRemote(
+function fakeCtx(
   presets: Map<string, FakePreset>,
   defaultId: { id: string },
   options: FakeOptions = {},
-): Pick<ClientRemote, 'agentPresets' | 'settings'> {
+): ClientContext {
   const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
   return {
-    agentPresets: {
-      list: () => {
-        record('list', {})
-        if (options.throwList === true) return Promise.reject(new Error('socket closed'))
-        if (options.failList !== undefined) return remoteFail(options.failList)
-        return remoteOk({
-          presets: [...presets].map(([id, preset]) => ({
-            id, trust: preset.trust, isDefault: id === defaultId.id,
+    remote: {
+      agentPresets: {
+        list: () => {
+          record('list', {})
+          if (options.failList !== undefined) return remoteFail(options.failList)
+          return remoteOk({
+            presets: [...presets].map(([id, preset]) => ({
+              id, trust: preset.trust, isDefault: id === defaultId.id,
+              ...preset.name === undefined ? {} : { name: preset.name },
+            })),
+            authorable: options.authorable ?? true,
+          })
+        },
+        read: (agentPreset: string) => {
+          record('read', { agentPreset })
+          if (options.failRead !== undefined) return remoteFail(options.failRead)
+          const preset = presets.get(agentPreset)
+          /* v8 ignore next -- every test reads an id the fake store holds */
+          if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`)
+          return remoteOk({
+            agentPreset,
+            trust: preset.trust,
+            content: preset.content,
             ...preset.name === undefined ? {} : { name: preset.name },
-          })),
-          authorable: options.authorable ?? true,
-        })
-      },
-      read: (agentPreset: string) => {
-        record('read', { agentPreset })
-        if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
-        if (options.failRead !== undefined) return remoteFail(options.failRead)
-        const preset = presets.get(agentPreset)
-        /* v8 ignore next -- every test reads an id the fake store holds */
-        if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`)
-        return remoteOk({
-          agentPreset,
-          trust: preset.trust,
-          content: preset.content,
-          ...preset.name === undefined ? {} : { name: preset.name },
-        })
-      },
-      // Arity is checked against the declaration, not against which arguments
-      // carry a value, so a short call rejects instead of answering. Reject
-      // one here too: the real face would, and a lenient double hid it once.
-      copy: (...args: [from: string, id: string, name?: string]) => {
-        if (args.length !== 3) {
-          return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`))
-        }
-        const [from, id, name] = args
-        record('copy', { from, id, ...name === undefined ? {} : { name } })
-        if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
-        if (options.failCopy !== undefined) return remoteFail(options.failCopy)
-        const source = presets.get(from)
-        /* v8 ignore next -- every test copies a source the fake store holds */
-        if (source === undefined) return remoteFail(`unknown preset ${from}`)
-        presets.set(id, {
-          trust: 'user',
-          content: source.content,
-          ...name === undefined ? {} : { name },
-        })
-        return remoteOk(undefined)
-      },
-      deletePreset: async (id: string) => {
-        record('deletePreset', { id })
-        await options.holdRemove
-        if (options.failRemove !== undefined) return await remoteFail(options.failRemove)
-        presets.delete(id)
-        return await remoteOk(undefined)
-      },
-    },
-    settings: {
-      canOpenAgentPresetDirectory: () => {
-        record('canOpenAgentPresetDirectory', {})
-        return options.throwCapability === true
-          ? Promise.reject(new Error('socket closed'))
-          : remoteOk(options.hasDocument ?? true)
-      },
-      update: (ns: string, patch: { default?: string }) => {
-        record('settings.update', { ns, patch })
-        if (options.failSettings !== undefined) return remoteFail(options.failSettings)
-        /* v8 ignore next -- the controller only ever sets `default` */
-        defaultId.id = patch.default ?? defaultId.id
-        return remoteOk({})
+          })
+        },
+        // Arity is checked against the declaration, not against which arguments
+        // carry a value, so a short call rejects instead of answering. Reject
+        // one here too: the real face would, and a lenient double hid it once.
+        copy: (...args: [from: string, id: string, name?: string]) => {
+          if (args.length !== 3) {
+            return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`))
+          }
+          const [from, id, name] = args
+          record('copy', { from, id, ...name === undefined ? {} : { name } })
+          if (options.failCopy !== undefined) return remoteFail(options.failCopy)
+          const source = presets.get(from)
+          /* v8 ignore next -- every test copies a source the fake store holds */
+          if (source === undefined) return remoteFail(`unknown preset ${from}`)
+          presets.set(id, {
+            trust: 'user',
+            content: source.content,
+            ...name === undefined ? {} : { name },
+          })
+          return remoteOk(undefined)
+        },
+        deletePreset: async (id: string) => {
+          record('deletePreset', { id })
+          await options.holdRemove
+          if (options.failRemove !== undefined) return await remoteFail(options.failRemove)
+          presets.delete(id)
+          return await remoteOk(undefined)
+        },
       },
-      openAgentPresetDirectory: (agentPreset: string) => {
-        record('openAgentPresetDirectory', { agentPreset })
-        if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
-        if (options.failOpen !== undefined) return remoteFail(options.failOpen)
-        return (options.hasDocument ?? true)
-          ? remoteOk({ opened: true })
-          : remoteOk({ opened: false, path: `/presets/${agentPreset}` })
+      settings: {
+        canOpenAgentPresetDirectory: () => {
+          record('canOpenAgentPresetDirectory', {})
+          return options.failCapability === undefined
+            ? remoteOk(options.hasDocument ?? true)
+            : remoteFail(options.failCapability)
+        },
+        update: (ns: string, patch: { default?: string }) => {
+          record('settings.update', { ns, patch })
+          if (options.failSettings !== undefined) return remoteFail(options.failSettings)
+          /* v8 ignore next -- the controller only ever sets `default` */
+          defaultId.id = patch.default ?? defaultId.id
+          return remoteOk({})
+        },
+        openAgentPresetDirectory: (agentPreset: string) => {
+          record('openAgentPresetDirectory', { agentPreset })
+          if (options.failOpen !== undefined) return remoteFail(options.failOpen)
+          return (options.hasDocument ?? true)
+            ? remoteOk({ opened: true })
+            : remoteOk({ opened: false, path: `/presets/${agentPreset}` })
+        },
       },
     },
-  } as unknown as Pick<ClientRemote, 'agentPresets' | 'settings'>
+  } as unknown as ClientContext
 }
 
 function seed(): Map<string, FakePreset> {
@@ -162,7 +153,7 @@ function harness(options: FakeOptions = {}) {
   let rosterChanges = 0
   const wired = { ...options, calls: options.calls ?? calls }
   const controller = new AgentPresetSectionController(
-    fakeRemote(presets, defaultId, wired),
+    fakeCtx(presets, defaultId, wired),
     () => { rosterChanges += 1 },
   )
   return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
@@ -175,8 +166,8 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft {
 }
 
 describe('loading the roster', () => {
-  it('still lists the roster when the opener capability cannot be read', async () => {
-    const { controller } = harness({ throwCapability: true })
+  it('still lists the roster when the opener capability is refused', async () => {
+    const { controller } = harness({ failCapability: 'no opener here' })
 
     await controller.load()
 
@@ -228,14 +219,6 @@ describe('loading the roster', () => {
     expect(state.error).toBe('not for you')
   })
 
-  it('folds a dead transport into the same error surface', async () => {
-    const { controller } = harness({ throwList: true })
-
-    await controller.load()
-
-    expect(controller.store.getSnapshot().status).toBe('error')
-    expect(controller.store.getSnapshot().error).toContain('socket closed')
-  })
 })
 
 describe('the read-only viewer', () => {
@@ -280,14 +263,6 @@ describe('the read-only viewer', () => {
     expect(controller.store.getSnapshot().error).toBe('no peeking')
   })
 
-  it('folds a dead transport into the same error surface', async () => {
-    const { controller } = harness({ throwRead: true })
-    await controller.load()
-
-    await controller.view('standard')
-
-    expect(controller.store.getSnapshot().error).toContain('socket closed')
-  })
 })
 
 describe('the copy dialog', () => {
@@ -423,17 +398,6 @@ describe('submitting a copy', () => {
     expect(rosterChanges()).toBe(0)
   })
 
-  it('folds a dead transport into the dialog error', async () => {
-    const { controller } = harness({ throwCopy: true })
-    await controller.load()
-    controller.beginCopy('standard')
-    controller.setCopyId('my-copy')
-
-    await controller.confirmCopy()
-
-    expect(copyOf(controller).error).toContain('socket closed')
-  })
-
   it('refuses to submit while blocked or already saving', async () => {
     const { controller, calls } = harness()
     await controller.load()
@@ -486,14 +450,6 @@ describe('the location action', () => {
     expect(controller.store.getSnapshot().error).toBe('not yours')
   })
 
-  it('folds a dead transport into the same error surface', async () => {
-    const { controller } = harness({ throwOpen: true })
-    await controller.load()
-
-    await controller.openLocation('mine')
-
-    expect(controller.store.getSnapshot().error).toContain('socket closed')
-  })
 })
 
 describe('deleting', () => {
@@ -552,25 +508,6 @@ describe('deleting', () => {
     expect(state.deleting).toBe(false)
   })
 
-  it('folds a dead transport into the same error surface', async () => {
-    const { controller, presets } = harness()
-    await controller.load()
-    presets.clear()
-    const broken = new AgentPresetSectionController(
-      {
-        agentPresets: {
-          list: () => Promise.reject(new Error('gone')),
-          deletePreset: () => Promise.reject(new Error('socket closed')),
-        },
-        settings: {},
-      } as unknown as Pick<ClientRemote, 'agentPresets' | 'settings'>,
-    )
-    broken.confirmDelete('mine')
-
-    await broken.remove()
-
-    expect(broken.store.getSnapshot().error).toContain('socket closed')
-  })
 })
 
 describe('a controller with no roster listener', () => {
@@ -580,7 +517,7 @@ describe('a controller with no roster listener', () => {
     const presets = seed()
     const defaultId = { id: 'standard' }
     const alone = new AgentPresetSectionController(
-      fakeRemote(presets, defaultId))
+      fakeCtx(presets, defaultId))
     await alone.load()
     alone.confirmDelete('mine')
 

+ 79 - 150
packages/client/ui-agent-preset/tests/settings-store.client.spec.ts

@@ -6,24 +6,19 @@
  */
 
 import { describe, expect, it } from 'vitest'
-import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
-import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type { RemoteErrorCode } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
-  AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
+  AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
 } from '../src/client/settings-store.ts'
 
-/** The two faces the row reads: the roster Remote and the settings wire. */
-interface FakeWire {
-  api: SettingsWireFace
-  remote: Pick<ClientRemote, 'agentPresets'>
-}
-
-/** Controller over a real mirror derived from the same fake wire. */
-function derivedController(wire: FakeWire) {
-  return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api))
+/** Controller over a real mirror derived from the same scripted context. */
+function derivedController(ctx: ClientContext) {
+  return new AgentPresetSettingsController(ctx, new SettingsDescribeMirror(ctx))
 }
 import { AgentPresetSeatController } from '../src/client/seat-store.ts'
 
@@ -34,60 +29,58 @@ interface Recorded { ns: string; ops: unknown }
 /** A roster Remote answering a fixed set of rows, or refusing. */
 function fakeRoster(
   presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
-  options: { failList?: string; failListCode?: string; throwOnList?: boolean } = {},
-): Pick<ClientRemote, 'agentPresets'> {
+  options: { failList?: string; failListCode?: RemoteErrorCode; settings?: object } = {},
+): ClientContext {
   return {
-    agentPresets: {
-      list: () => {
-        if (options.throwOnList === true) return Promise.reject(new Error('socket closed'))
-        return Promise.resolve(options.failList === undefined
-          ? { ok: true as const, value: { presets, authorable: true } }
-          : {
-            ok: false as const,
-            error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} },
-          })
+    remote: {
+      ...options.settings === undefined ? {} : { settings: options.settings },
+      agentPresets: {
+        list: () => {
+          return Promise.resolve(options.failList === undefined
+            ? { ok: true as const, value: { presets, authorable: true } }
+            : {
+              ok: false as const,
+              error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}),
+            })
+        },
       },
     },
-  } as unknown as Pick<ClientRemote, 'agentPresets'>
+  } as unknown as ClientContext
 }
 
-/** A wire whose roster and write outcome the test controls. */
+/** A context whose roster and settings write outcome the test controls. */
 function fakeApi(
   presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
   options: {
     writes?: Recorded[]
     failWrite?: string
     failList?: string
-    failWriteWith?: Error
     readOnly?: boolean
   } = {},
-): FakeWire {
-  const api = {
-    settings: {
-      // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false
-      // and the row disables its control instead of offering a refused write.
-      describe: () => Promise.resolve({
-        ok: true as const,
-        value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
-      }),
-      update: (ns: string, patch: { default?: unknown }) => {
-        options.writes?.push({ ns, ops: patch })
-        if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
-        if (options.failWrite !== undefined) {
-          return Promise.resolve({ ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } })
-        }
-        // A committed write moves the roster's default.
-        for (const preset of presets) {
-          preset.isDefault = preset.id === patch.default
-        }
-        return Promise.resolve({ ok: true as const, value: {} })
-      },
+): ClientContext {
+  const settings = {
+    // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false
+    // and the row disables its control instead of offering a refused write.
+    describe: () => Promise.resolve({
+      ok: true as const,
+      value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
+    }),
+    update: (ns: string, patch: { default?: unknown }) => {
+      options.writes?.push({ ns, ops: patch })
+      if (options.failWrite !== undefined) {
+        return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', options.failWrite, {}) })
+      }
+      // A committed write moves the roster's default.
+      for (const preset of presets) {
+        preset.isDefault = preset.id === patch.default
+      }
+      return Promise.resolve({ ok: true as const, value: {} })
     },
-  } as unknown as SettingsWireFace
-  return {
-    api,
-    remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }),
   }
+  return fakeRoster(presets, {
+    settings,
+    ...options.failList === undefined ? {} : { failList: options.failList },
+  })
 }
 
 describe('the agent-preset settings controller', () => {
@@ -162,13 +155,10 @@ describe('the agent-preset settings controller', () => {
   })
 
   it('treats an unavailable optional namespace as an empty roster', async () => {
-    const controller = derivedController({
-      api: {} as SettingsWireFace,
-      remote: fakeRoster([], {
-        failList: 'no active Remote method exports this endpoint',
-        failListCode: 'invocation-unavailable',
-      }),
-    })
+    const controller = derivedController(fakeRoster([], {
+      failList: 'no active Remote method exports this endpoint',
+      failListCode: 'gateway/invocation-unavailable',
+    }))
 
     await controller.load()
 
@@ -252,37 +242,6 @@ describe('the agent-preset settings controller', () => {
     expect(controller.store.getSnapshot().status).toBe('ready')
   })
 
-  it('reads an Error\'s message and stringifies anything else', () => {
-    // A transport rejects with an Error, but a host or a runtime can reject
-    // with anything and the surface still has to say something.
-    expect(messageOf(new Error('boom'))).toBe('boom')
-    expect(messageOf({ code: 7 })).toBe('[object Object]')
-  })
-
-  it('reports a transport that rejects rather than answering', async () => {
-    const controller = derivedController({
-      api: {} as SettingsWireFace,
-      remote: fakeRoster([], { throwOnList: true }),
-    })
-
-    await controller.load()
-
-    expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
-  })
-
-  it('reports a transport that rejects mid-write and keeps the old default showing', async () => {
-    const controller = derivedController(fakeApi([
-      { id: 'standard', trust: 'system', isDefault: true },
-      { id: 'mine', trust: 'user', isDefault: false },
-    ], { failWriteWith: new Error('socket closed') }))
-    await controller.load()
-
-    await controller.select('mine')
-
-    // The value snaps back because the host never took it; a picker still
-    // showing "mine" would be claiming a default that does not exist.
-    expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
-  })
 })
 
 describe('the new-session chip controller', () => {
@@ -294,39 +253,36 @@ describe('the new-session chip controller', () => {
       writes?: Recorded[]
       failSelect?: string
       failList?: string
-      failListCode?: string
-      throwOn?: 'list' | 'select'
+      failListCode?: RemoteErrorCode
     } = {},
   ): AgentPresetSeatController {
-    const remote = {
-      agentPresets: {
-        list: () => {
-          if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
-          return Promise.resolve(options.failList === undefined
-            ? { ok: true as const, value: { presets, authorable: true } }
-            : {
-              ok: false as const,
-              error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} },
-            })
-        },
-        select: (agentId: SessionId, agentPreset: string) => {
-          if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
-          options.writes?.push({ ns: 'select', ops: agentPreset })
-          return Promise.resolve(options.failSelect === undefined
-            ? { ok: true as const, value: agentPreset }
-            : {
-              ok: false as const,
-              error: {
-                code: 'agent-preset-locked',
-                message: options.failSelect,
-                details: { sessionId: agentId, agentPreset },
-              },
-            })
+    const ctx = {
+      remote: {
+        agentPresets: {
+          list: () => {
+            return Promise.resolve(options.failList === undefined
+              ? { ok: true as const, value: { presets, authorable: true } }
+              : {
+                ok: false as const,
+                error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}),
+              })
+          },
+          select: (agentId: SessionId, agentPreset: string) => {
+            options.writes?.push({ ns: 'select', ops: agentPreset })
+            return Promise.resolve(options.failSelect === undefined
+              ? { ok: true as const, value: agentPreset }
+              : {
+                ok: false as const,
+                error: new RemoteError('agent-preset/locked', options.failSelect, {
+                  sessionId: agentId, agentPreset,
+                }),
+              })
+          },
         },
       },
-    } as unknown as Pick<ClientRemote, 'agentPresets'>
+    } as unknown as ClientContext
     return new AgentPresetSeatController(
-      remote,
+      ctx,
       typeof current === 'function' ? current : () => current,
     )
   }
@@ -385,7 +341,7 @@ describe('the new-session chip controller', () => {
   it('opens on nothing when the optional namespace is unavailable', async () => {
     const controller = chip([], undefined, {
       failList: 'no active Remote method exports this endpoint',
-      failListCode: 'invocation-unavailable',
+      failListCode: 'gateway/invocation-unavailable',
     })
 
     await controller.load()
@@ -505,24 +461,6 @@ describe('the new-session chip controller', () => {
     expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' })
   })
 
-  it('falls back to the default when the switch never reaches the host', async () => {
-    const controller = chip(
-      ROSTER,
-      {
-        id: 's1' as SessionId,
-        blank: true,
-        projectionValues: { agentPreset: 'standard' },
-      },
-      { throwOn: 'select' },
-    )
-    await controller.load()
-
-    await controller.select('minimal')
-
-    expect(controller.store.getSnapshot())
-      .toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
-  })
-
   it('ignores a pick while a switch is in flight', async () => {
     const writes: Recorded[] = []
     const controller = chip(ROSTER, {
@@ -559,21 +497,12 @@ describe('the new-session chip controller', () => {
     expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
   })
 
-  it('reports a transport that rejects the roster read', async () => {
-    const controller = chip(ROSTER, undefined, { throwOn: 'list' })
-
-    await controller.load()
-
-    expect(controller.store.getSnapshot().error).toBe('socket closed')
-  })
-
   it('degrades to a read-only row while the mirror holds no answer', async () => {
-    const controller = derivedController({
-      // The roster answered; the mirror's read is what failed, so the row
-      // shows the current default without offering a write it never confirmed.
-      api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace,
-      remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]),
-    })
+    // The roster answered; the mirror's read is what failed, so the row
+    // shows the current default without offering a write it never confirmed.
+    const controller = derivedController(fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }], {
+      settings: { describe: () => Promise.reject(new Error('socket closed')) },
+    }))
 
     await controller.load()
 

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

@@ -5,7 +5,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import {
-  SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
+  RemoteError, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
@@ -127,7 +127,7 @@ describe('Chat inject API', () => {
 
     b.openWorkspacePath.mockResolvedValueOnce({
       ok: false,
-      error: { code: 'internal', message: 'xdg-open is not available', details: {} },
+      error: new RemoteError('gateway/internal', 'xdg-open is not available', {}),
     })
     await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available')
     await b.runtime.dispose()

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

@@ -2,8 +2,7 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
-import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type {
   ChatConversationViewNode, ConversationNode,

+ 1 - 2
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -5,8 +5,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type {
   AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
-import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { StatsLine, deriveStats, formatDuration, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'

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

@@ -16,10 +16,9 @@ import type {
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
 import { ChatView } from '../src/client/chat/ChatView.tsx'
@@ -2275,7 +2274,7 @@ describe('ChatView', () => {
   it('shows open error and loading states', () => {
     const h = makeHarness({}, {
       openState: 'error',
-      openError: { code: 'internal', message: 'boom' } as never,
+      openError: { code: 'gateway/internal', message: 'boom' } as never,
     })
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()

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

@@ -2,7 +2,7 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type {
   SessionListState, SessionSnapshot,
@@ -15,7 +15,6 @@ import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversa
 import type {
   DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
-import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
 import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'

+ 5 - 5
packages/client/ui-commands/src/client/service.ts

@@ -395,7 +395,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * the outcome renders as a persistent flow node — the composer never
    * echoes it. A handler error result reports an error outcome so the
    * composer keeps the submission (draft and images) for correction.
-   * Transport failures throw.
+   * A refused call throws.
    */
   private async execute(
     session: ClientSessionContext,
@@ -441,9 +441,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * Fire-and-forget execute for the internal ('handled') paths. Outcomes are
    * NOT surfaced here: the host executor durably logs the command lifecycle
    * (`command/run`/`command/done`), and the mux-broadcast events render as a
-   * persistent flow node on every tab. Only a transport/admission failure —
-   * which never entered a handler and therefore never logged — falls back to
-   * the composer notice as immediate feedback.
+   * persistent flow node on every tab. Only an admission failure — which never
+   * entered a handler and therefore never logged — falls back to the composer
+   * notice as immediate feedback.
    */
   private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
     void this.execute(session, line).then(
@@ -468,7 +468,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     })
   }
 
-  /** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
+  /** Route an admission failure to the session's composer notice channel (scope gone = attempt died with it). */
   private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
     const actx = this.scopeFor(id)
     if (actx === undefined) return

+ 5 - 9
packages/client/ui-commands/tests/service.client.spec.ts

@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
 import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
@@ -44,8 +44,8 @@ interface BenchOptions {
 
 /**
  * Fold one programmed answer into the generated Remote face's outcome: a
- * resolved value is the ok branch, a rejection is the transport failure the
- * carrier reports in the error branch instead of throwing at the caller.
+ * resolved value is the ok branch, a rejection is the carrier failure the
+ * Remote face reports in the error branch instead of throwing at the caller.
  * @param produce - the scripted answer for one Remote method.
  * @returns the carried result the service reads.
  */
@@ -55,11 +55,7 @@ async function carried<T>(produce: () => Promise<T>) {
   } catch (error) {
     return {
       ok: false as const,
-      error: {
-        code: 'internal',
-        message: error instanceof Error ? error.message : String(error),
-        details: {},
-      },
+      error: new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {}),
     }
   }
 }
@@ -660,7 +656,7 @@ describe('detached admission notices', () => {
     expect(notices).toEqual([{
       scope: sid('s1'),
       level: 'error',
-      text: 'command.execute failed: internal: network down',
+      text: 'command.execute failed: gateway/internal: network down',
     }])
   })
 

+ 3 - 2
packages/client/ui-conversation/src/client/image-labels.ts

@@ -15,8 +15,9 @@ export function imageSizeText(bytes: number): string {
 }
 
 /**
- * Product copy for a host attachment rejection (the `attachment-error`
- * `details.reason`). User-solvable reasons name the limit and the way out;
+ * Product copy for a host attachment rejection (the `details.reason` of
+ * `session/attachment-invalid` or `subagent/attachment-unsupported`).
+ * User-solvable reasons name the limit and the way out;
  * reasons the user cannot act on fold into one send-failed line carrying the
  * reason code for a bug report.
  * @param t - the conversation-namespace translate.

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

@@ -186,10 +186,10 @@ export class InputHub implements SessionInputResolver {
   /**
    * Steer every still-pending queued message into the running turn, in FIFO
    * order — the same strict-steer operation as the queue dock's per-row
-   * button. A turn closing mid-way (`steer-unavailable`) or a row already
-   * claimed by the agent (`queue-item-not-found`) converges silently, while a
+   * button. A turn closing mid-way (`session/steer-unavailable`) or a row already
+   * claimed by the agent (`session/queue-item-not-found`) converges silently, while a
    * genuine failure surfaces as one composer notice. Repeated triggers
-   * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found`
+   * (e.g. two rapid empty-draft chords) rely on that `session/queue-item-not-found`
    * convergence: the snapshot may still list a row the host already steered,
    * and the duplicate strict steer is a silent no-op.
    * @param session - the addressed host session.
@@ -201,7 +201,7 @@ export class InputHub implements SessionInputResolver {
     for (const item of queued) {
       const result = await session.updateQueue(item.id, { kind: 'steer' })
       if (result.ok) continue
-      if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return
+      if (result.error.code === 'session/steer-unavailable' || result.error.code === 'session/queue-item-not-found') return
       shell.notify('error', this.t('queue.steerFailed'))
       return
     }

Vissa filer visades inte eftersom för många filer har ändrats