Parcourir la source

feat(api-gateway): unify Remote streams and events

imccyu il y a 1 mois
Parent
commit
3d6d595d79
48 fichiers modifiés avec 7918 ajouts et 538 suppressions
  1. 5 7
      examples/acp-agent/tests/fixtures/child-question-tripwire.ts
  2. 193 100
      packages/api/gateway/src/client/index.ts
  3. 476 0
      packages/api/gateway/src/client/journal-stream.ts
  4. 339 0
      packages/api/gateway/src/client/remote-events.ts
  5. 210 0
      packages/api/gateway/src/client/remote-stream.ts
  6. 88 0
      packages/api/gateway/src/client/snapshot-stream.ts
  7. 348 0
      packages/api/gateway/src/client/stream-client.ts
  8. 556 44
      packages/api/gateway/src/index.ts
  9. 399 0
      packages/api/gateway/src/stream-protocol.ts
  10. 188 0
      packages/api/gateway/src/stream-server.ts
  11. 98 0
      packages/api/gateway/src/types.ts
  12. 303 0
      packages/api/gateway/tests/control-retry.client.spec.ts
  13. 1046 0
      packages/api/gateway/tests/gateway-stream.host.spec.ts
  14. 1542 71
      packages/api/gateway/tests/gateway.client.spec.ts
  15. 62 0
      packages/api/gateway/tests/gateway.host.spec.ts
  16. 335 0
      packages/api/gateway/tests/journal-stream.client.spec.ts
  17. 287 0
      packages/api/gateway/tests/remote-event-protocol.host.spec.ts
  18. 68 0
      packages/api/gateway/tests/stream-protocol.host.spec.ts
  19. 246 0
      packages/api/gateway/tests/stream-server.host.spec.ts
  20. 1 1
      packages/core/agent/src/dispatch.ts
  21. 3 12
      packages/core/agent/src/index.ts
  22. 31 30
      packages/core/agent/src/runtime-types.ts
  23. 19 0
      packages/core/agent/src/types.ts
  24. 5 1
      packages/core/agent/tests/agent.spec.ts
  25. 3 5
      packages/experimental/webworker-runtime/src/client/api-client.ts
  26. 155 50
      packages/experimental/webworker-runtime/src/client/client.ts
  27. 2 0
      packages/experimental/webworker-runtime/src/client/index.ts
  28. 3 1
      packages/experimental/webworker-runtime/src/index.ts
  29. 52 1
      packages/experimental/webworker-runtime/src/transport/frames.ts
  30. 63 12
      packages/experimental/webworker-runtime/src/transport/tunnel.ts
  31. 7 0
      packages/experimental/webworker-runtime/src/worker-host.ts
  32. 106 5
      packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts
  33. 113 0
      packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts
  34. 2 37
      packages/interaction/user-approval/src/index.ts
  35. 61 0
      packages/interaction/user-approval/src/types.ts
  36. 4 2
      packages/interaction/user-questions/src/index.ts
  37. 30 6
      packages/interaction/user-questions/src/types.ts
  38. 47 18
      packages/typert/generator/src/analyzer.ts
  39. 6 3
      packages/typert/generator/src/emitter.ts
  40. 1 0
      packages/typert/generator/src/model.ts
  41. 6 0
      packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts
  42. 1 1
      packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts
  43. 40 24
      packages/typert/generator/tests/remote-model.spec.ts
  44. 70 25
      packages/typert/protocol/src/index.ts
  45. 150 58
      packages/typert/protocol/src/types.ts
  46. 72 2
      packages/typert/protocol/tests/protocol.spec.ts
  47. 38 21
      packages/typert/registry/src/service.ts
  48. 38 1
      packages/typert/registry/tests/typert.spec.ts

+ 5 - 7
examples/acp-agent/tests/fixtures/child-question-tripwire.ts

@@ -1,17 +1,15 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import '@deepseek-ai/dsh-user-questions'
 import '@deepseek-ai/dsh-user-questions'
 
 
-/** Snapshot-only provider whose invocation means the child guard failed. */
+/** Snapshot-only answerer whose invocation means the child guard failed. */
 export const name = 'child-question-tripwire'
 export const name = 'child-question-tripwire'
 
 
-/** User-interaction service required by the tripwire provider. */
+/** User-interaction service required by the tripwire answerer. */
 export const inject = ['userQuestions']
 export const inject = ['userQuestions']
 
 
-/** Register a provider that must remain unreachable for the delegated call. */
+/** Register an answerer that must remain unreachable for the delegated call. */
 export function apply(ctx: Context): void {
 export function apply(ctx: Context): void {
-  ctx.userQuestions.registerProvider({
-    async ask() {
-      throw new Error('snapshot tripwire: delegated question reached the UI provider')
-    },
+  ctx.on('user-questions/request', async () => {
+    throw new Error('snapshot tripwire: delegated question reached the UI answerer')
   })
   })
 }
 }

+ 193 - 100
packages/api/gateway/src/client/index.ts

@@ -5,10 +5,13 @@
  */
  */
 
 
 import { Service } from '@deepseek-ai/cordis'
 import { Service } from '@deepseek-ai/cordis'
-import type { Context, Events } from '@deepseek-ai/cordis'
-import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type { Context } from '@deepseek-ai/cordis'
+import type {
+  ConnectionHandle,
+} from '@deepseek-ai/dsh-client-connection/client'
 import type {
 import type {
   InvocationDescriptor,
   InvocationDescriptor,
+  TypertClientEventListener,
   TypertClientRemote,
   TypertClientRemote,
   RemoteResult,
   RemoteResult,
   TypertCodec,
   TypertCodec,
@@ -16,6 +19,29 @@ import type {
   TypertRemoteContribution,
   TypertRemoteContribution,
   TypertRemoteEvent,
   TypertRemoteEvent,
 } from '@deepseek-ai/dsh-typert-protocol'
 } from '@deepseek-ai/dsh-typert-protocol'
+import {
+  RemoteStreamCarrierError,
+  RemoteStreamError,
+  RemoteStreamMuxClient,
+} from './stream-client.ts'
+import { ClientRemoteEvents } from './remote-events.ts'
+import {
+  RemoteStream,
+  type RemoteStreamOptions,
+} from './remote-stream.ts'
+
+export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts'
+export { RemoteJournalStream } from './journal-stream.ts'
+export type {
+  RemoteJournalChange,
+  RemoteJournalFrame,
+  RemoteJournalStreamOptions,
+  RemoteStreamFactory,
+} from './journal-stream.ts'
+export { RemoteStream } from './remote-stream.ts'
+export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts'
+export { RemoteSnapshotStream } from './snapshot-stream.ts'
+export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts'
 
 
 interface MountToken {
 interface MountToken {
   active: boolean
   active: boolean
@@ -47,11 +73,21 @@ interface BoundContextIdentity {
   readonly value: unknown
   readonly value: unknown
 }
 }
 
 
+interface PreparedClientInvocation {
+  readonly endpoint: string
+  readonly args: Readonly<Record<string, unknown>>
+  readonly signal: AbortSignal
+}
+
 interface RemoteNamespaceHandle {
 interface RemoteNamespaceHandle {
   readonly service: RemoteNamespaceService
   readonly service: RemoteNamespaceService
   readonly dispose: TypertDisposer
   readonly dispose: TypertDisposer
 }
 }
 
 
+interface LoaderReadiness {
+  await(): Promise<unknown>
+}
+
 /** One descriptor's mounted variants, for the group disposer to unwind. */
 /** One descriptor's mounted variants, for the group disposer to unwind. */
 interface InstalledMethod {
 interface InstalledMethod {
   readonly descriptor: InvocationDescriptor
   readonly descriptor: InvocationDescriptor
@@ -60,8 +96,15 @@ interface InstalledMethod {
   scoped: boolean
   scoped: boolean
 }
 }
 
 
-/** Typed Remote service augmented by generated direct namespaces. */
-export type ClientRemote = TypertClientRemote
+/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */
+export interface ClientRemote extends TypertClientRemote {
+  /**
+   * Create one independently cancellable, reconnecting logical stream.
+   * @param options - domain-owned opener and generation-end classification.
+   * @returns a single-consumer stream annotated with physical generation ids.
+   */
+  $stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item>
+}
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
   interface Context {
   interface Context {
@@ -81,28 +124,46 @@ export function apply(ctx: Context): void {
   new ClientRemoteService(ctx)
   new ClientRemoteService(ctx)
 }
 }
 
 
-/** One subscribed listener after `$on` erased its per-event argument list. */
-type RemoteEventListener = (...args: never[]) => void
-
-/**
- * One subscription, identified by the registration rather than by its listener:
- * two fibers may subscribe the same function object to the same event, and each
- * disposer must retire only its own registration.
- */
-interface RemoteEventSubscription {
-  readonly listener: RemoteEventListener
-}
-
-class ClientRemoteService extends Service implements TypertClientRemote {
+class ClientRemoteService extends Service implements ClientRemote {
   private readonly ownerCtx: Context
   private readonly ownerCtx: Context
+  private readonly connection: ConnectionHandle
   private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
   private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
-  private readonly subscriptions = new Map<string, RemoteEventSubscription[]>()
+  private readonly streams = new RemoteStreamMuxClient()
+  private readonly events: ClientRemoteEvents
   private mutations = Promise.resolve()
   private mutations = Promise.resolve()
 
 
   constructor(ctx: Context) {
   constructor(ctx: Context) {
     super(ctx, 'remote')
     super(ctx, 'remote')
     this.ownerCtx = ctx
     this.ownerCtx = ctx
-    ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions')
+    const connection = ctx.get('connection') as ConnectionHandle
+    this.connection = connection
+    this.events = new ClientRemoteEvents(
+      ctx,
+      connection,
+      (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal),
+    )
+    if (connection.rpc.open === undefined) this.streams.start()
+    let disposed = false
+    let loop: ReturnType<ConnectionHandle['start']> | undefined
+    const start = (): void => {
+      if (disposed) return
+      loop = connection.start({
+        onConnected: () => { this.ownerCtx.emit('connection/reset') },
+      })
+    }
+    const loader = ctx.get('loader') as LoaderReadiness | undefined
+    if (loader === undefined) start()
+    else void loader.await().then(start, () => {})
+    ctx.effect(() => async () => {
+      disposed = true
+      loop?.stop()
+      await this.events.dispose()
+      await this.streams.close()
+    }, 'api-gateway.client.transport')
+  }
+
+  $stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item> {
+    return new RemoteStream(this.connection, options)
   }
   }
 
 
   async $mount(contribution: TypertRemoteContribution): ReturnType<TypertClientRemote['$mount']> {
   async $mount(contribution: TypertRemoteContribution): ReturnType<TypertClientRemote['$mount']> {
@@ -117,60 +178,24 @@ class ClientRemoteService extends Service implements TypertClientRemote {
 
 
   $on<Event extends TypertRemoteEvent>(
   $on<Event extends TypertRemoteEvent>(
     event: Event,
     event: Event,
-    listener: Events[Event],
-  ): ReturnType<TypertClientRemote['$on']> {
-    // The table is keyed by the runtime event name, so the argument list this
-    // signature pins per event cannot survive in it; `$deliver` restores it
-    // from the frame the Host emitted for that same name.
-    const subscription: RemoteEventSubscription = { listener }
-    const owned = this.ctx.effect(() => {
-      const listeners = this.listeners(event)
-      listeners.push(subscription)
-      return () => {
-        const at = listeners.indexOf(subscription)
-        /* v8 ignore next -- listener */
-        if (at >= 0) listeners.splice(at, 1)
-      }
-    }, `api-gateway.client.$on(${JSON.stringify(event)})`)
-    return () => { void owned() }
-  }
-
-  /**
-   * Deliver one forwarded event in registration order, isolating a listener
-   * that fails either synchronously or by rejecting a returned promise; see
-   * {@link TypertClientRemote.$dispatch} for the caller contract.
-   */
-  $dispatch(event: string, args: readonly unknown[]): void {
-    const listeners = this.subscriptions.get(event)
-    if (listeners === undefined) return
-    // Snapshot: a listener may subscribe or dispose during delivery, and this
-    // round's recipients are the ones registered when the frame arrived.
-    for (const { listener } of [...listeners]) {
-      const report = (error: unknown): void => {
-        console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
-      }
-      try {
-        /* oxlint-disable-next-line typescript/no-confusing-void-expression --
-         * The declared return is void, so nobody awaits an async listener; the
-         * runtime value is still a promise, and reading it is the only way to
-         * keep its rejection inside this containment instead of surfacing as an
-         * unhandled one. */
-        const settled: unknown = listener(...args as never[])
-        if (settled instanceof Promise) settled.catch(report)
-      } catch (error) {
-        report(error)
-      }
-    }
-  }
-
-  /** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */
-  private listeners(event: string): RemoteEventSubscription[] {
-    let listeners = this.subscriptions.get(event)
-    if (listeners === undefined) {
-      listeners = []
-      this.subscriptions.set(event, listeners)
-    }
-    return listeners
+    listener: TypertClientEventListener<Event>,
+  ): () => void {
+    return this.events.subscribe(this.ctx, event, listener)
+  }
+
+  /** Open one Remote stream and normalize a worker-local carrier's structural failures. */
+  private openRemoteStream(
+    endpoint: string,
+    payload: unknown,
+    signal: AbortSignal,
+    noConnection = `client api: ${endpoint} has no active Connection`,
+  ): AsyncIterable<unknown> {
+    const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
+    if (connection === undefined) throw new Error(noConnection)
+    const local = connection.rpc.open?.('/api', endpoint, payload, signal)
+    return local === undefined
+      ? this.streams.open(endpoint, payload, signal)
+      : normalizeConnectionStream(local)
   }
   }
 
 
   private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
   private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
@@ -331,12 +356,12 @@ class ClientRemoteService extends Service implements TypertClientRemote {
     scoped: ScopedMethod | undefined,
     scoped: ScopedMethod | undefined,
     callerCtx: Context,
     callerCtx: Context,
     values: readonly unknown[],
     values: readonly unknown[],
-  ): Promise<RemoteResult<unknown>> {
+  ): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
     if (scoped !== undefined) {
     if (scoped !== undefined) {
-      const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
-      const identity = binder?.identity(callerCtx)
+      const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
+      const identity = adapter?.identity(callerCtx)
       if (identity !== undefined) {
       if (identity !== undefined) {
-        return this.invoke(
+        return this.invokeSelected(
           scoped.descriptor,
           scoped.descriptor,
           scoped.projection,
           scoped.projection,
           scoped.token,
           scoped.token,
@@ -347,14 +372,28 @@ class ClientRemoteService extends Service implements TypertClientRemote {
       }
       }
     }
     }
     if (direct !== undefined) {
     if (direct !== undefined) {
-      return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
+      return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values)
     }
     }
     if (scoped !== undefined) {
     if (scoped !== undefined) {
-      return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
+      return this.invokeSelected(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
     }
     }
     throw new Error('client api: Remote method is no longer mounted')
     throw new Error('client api: Remote method is no longer mounted')
   }
   }
 
 
+  private invokeSelected(
+    descriptor: InvocationDescriptor,
+    projection: ScopedProjection | undefined,
+    token: MountToken,
+    callerCtx: Context,
+    values: readonly unknown[],
+    boundIdentity?: BoundContextIdentity,
+  ): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
+    if (descriptor.mode === 'stream') {
+      return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity)
+    }
+    return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity)
+  }
+
   private async invoke(
   private async invoke(
     descriptor: InvocationDescriptor,
     descriptor: InvocationDescriptor,
     projection: ScopedProjection | undefined,
     projection: ScopedProjection | undefined,
@@ -365,6 +404,48 @@ class ClientRemoteService extends Service implements TypertClientRemote {
   ): Promise<RemoteResult<unknown>> {
   ): Promise<RemoteResult<unknown>> {
     const endpoint = endpointOf(descriptor)
     const endpoint = endpointOf(descriptor)
     if (!token.active) return withdrawn(endpoint)
     if (!token.active) return withdrawn(endpoint)
+    const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity)
+    const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
+    if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
+    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 }
+      return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
+    } catch (error) {
+      // Carrier throws (offline, abort, a rejected result payload) are outcomes
+      // of the call, not assembly faults, so they join the same error branch.
+      return carrierFailure(endpoint, error)
+    }
+  }
+
+  private async *invokeStream(
+    descriptor: InvocationDescriptor,
+    projection: ScopedProjection | undefined,
+    token: MountToken,
+    callerCtx: Context,
+    values: readonly unknown[],
+    boundIdentity?: BoundContextIdentity,
+  ): AsyncGenerator {
+    const endpoint = endpointOf(descriptor)
+    if (!token.active) throw new Error(withdrawn(endpoint).error.message)
+    const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity)
+    const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal)
+    for await (const value of stream) {
+      if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message)
+      yield parse(descriptor.result, value, endpoint, 'result')
+    }
+  }
+
+  private prepareInvocation(
+    descriptor: InvocationDescriptor,
+    projection: ScopedProjection | undefined,
+    token: MountToken,
+    callerCtx: Context,
+    values: readonly unknown[],
+    boundIdentity?: BoundContextIdentity,
+  ): PreparedClientInvocation {
+    const endpoint = endpointOf(descriptor)
     const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
     const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
     const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
     const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
     if (values.length !== expected && !hasCallerSignal) {
     if (values.length !== expected && !hasCallerSignal) {
@@ -377,14 +458,14 @@ class ClientRemoteService extends Service implements TypertClientRemote {
     }
     }
     const args = Object.create(null) as Record<string, unknown>
     const args = Object.create(null) as Record<string, unknown>
     if (projection !== undefined) {
     if (projection !== undefined) {
-      const binder = boundIdentity === undefined
+      const adapter = boundIdentity === undefined
         ? this.ownerCtx.typert.contexts.getClient(projection.context)
         ? this.ownerCtx.typert.contexts.getClient(projection.context)
         : undefined
         : undefined
-      if (boundIdentity === undefined && binder === undefined) {
-        throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
+      if (boundIdentity === undefined && adapter === undefined) {
+        throw new Error(`client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`)
       }
       }
       const identity = boundIdentity === undefined
       const identity = boundIdentity === undefined
-        ? binder?.identity(callerCtx)
+        ? adapter?.identity(callerCtx)
         : boundIdentity.value
         : boundIdentity.value
       if (identity === undefined) {
       if (identity === undefined) {
         throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
         throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
@@ -398,22 +479,11 @@ class ClientRemoteService extends Service implements TypertClientRemote {
       if (value !== undefined) args[parameter.wire] = value
       if (value !== undefined) args[parameter.wire] = value
       valueIndex += 1
       valueIndex += 1
     })
     })
-    const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
-    if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
     const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
     const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
     const signal = callerSignal === undefined
     const signal = callerSignal === undefined
       ? token.abort.signal
       ? token.abort.signal
       : AbortSignal.any([token.abort.signal, callerSignal])
       : AbortSignal.any([token.abort.signal, callerSignal])
-    try {
-      const result = await connection.rpc.call('/api', endpoint, { args }, signal)
-      if (!mountActive(token)) return withdrawn(endpoint)
-      if (!result.ok) return { ok: false, error: result.error }
-      return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
-    } catch (error) {
-      // Carrier throws (offline, abort, a rejected result payload) are outcomes
-      // of the call, not assembly faults, so they join the same error branch.
-      return carrierFailure(endpoint, error)
-    }
+    return { endpoint, args, signal }
   }
   }
 }
 }
 
 
@@ -422,7 +492,7 @@ type InvokeRemote = (
   scoped: ScopedMethod | undefined,
   scoped: ScopedMethod | undefined,
   callerCtx: Context,
   callerCtx: Context,
   args: readonly unknown[],
   args: readonly unknown[],
-) => Promise<RemoteResult<unknown>>
+) => Promise<RemoteResult<unknown>> | AsyncIterable<unknown>
 
 
 class RemoteNamespaceService extends Service {
 class RemoteNamespaceService extends Service {
   private readonly methods = new Map<string, RemoteMethodRecord>()
   private readonly methods = new Map<string, RemoteMethodRecord>()
@@ -477,7 +547,7 @@ class RemoteNamespaceService extends Service {
       Object.defineProperty(this, method, {
       Object.defineProperty(this, method, {
         configurable: true,
         configurable: true,
         enumerable: true,
         enumerable: true,
-        get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
+        get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown {
           const callerCtx = this.ctx
           const callerCtx = this.ctx
           const current = this.methods.get(method)
           const current = this.methods.get(method)
           const direct = current?.direct
           const direct = current?.direct
@@ -620,14 +690,37 @@ function parse(codec: TypertCodec, value: unknown, endpoint: string, field: stri
 }
 }
 
 
 /** The namespace retired before or during the call, so no request outcome exists. */
 /** The namespace retired before or during the call, so no request outcome exists. */
-function withdrawn(endpoint: string): RemoteResult<never> {
+function withdrawn(endpoint: string): Extract<RemoteResult<never>, { readonly ok: false }> {
   return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
   return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
 }
 }
 
 
-function carrierFailure(endpoint: string, error: unknown): RemoteResult<never> {
+function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
   return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
   return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
 }
 }
 
 
-function internalFailure(message: string): RemoteResult<never> {
+function internalFailure(message: string): Extract<RemoteResult<never>, { readonly ok: false }> {
   return { ok: false, error: { code: 'internal', message, details: {} } }
   return { ok: false, error: { code: 'internal', message, details: {} } }
 }
 }
+
+type MarkedConnectionStreamFailure = Error & {
+  readonly dshRemoteStreamFailure?:
+    | { readonly kind: 'remote'; readonly code: string; readonly details: object }
+    | { readonly kind: 'carrier' }
+}
+
+/** Preserve Gateway error classes across a worker transport's separately bundled page half. */
+async function *normalizeConnectionStream(source: AsyncIterable<unknown>): AsyncGenerator {
+  try {
+    yield * source
+  } catch (error) {
+    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)
+    }
+    if (marker?.kind === 'carrier') {
+      throw new RemoteStreamCarrierError(error.message, { cause: error })
+    }
+    throw error
+  }
+}

+ 476 - 0
packages/api/gateway/src/client/journal-stream.ts

@@ -0,0 +1,476 @@
+/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */
+
+import { RemoteStreamCarrierError } from './stream-client.ts'
+import type {
+  RemoteStream,
+  RemoteStreamItem,
+  RemoteStreamOptions,
+} from './remote-stream.ts'
+
+/** Transport-neutral opening cursor or journal entry. */
+export type RemoteJournalFrame<Entry, Cursor> =
+  | { readonly type: 'opened'; readonly cursor: Cursor }
+  | { readonly type: 'entry'; readonly entry: Entry }
+
+/** One committed journal-window update. */
+export type RemoteJournalChange<Page, Entry> =
+  | {
+    readonly type: 'replace'
+    readonly page: Page
+    readonly entries: readonly Entry[]
+    readonly hasMore: boolean
+  }
+  | {
+    readonly type: 'prepend'
+    readonly page: Page
+    readonly entries: readonly Entry[]
+    readonly hasMore: boolean
+  }
+  | { readonly type: 'append'; readonly entry: Entry }
+
+type JournalStreamItem<Entry, Cursor> = RemoteStreamItem<RemoteJournalFrame<Entry, Cursor>>
+
+/** Gateway capability used to create one reconnecting Remote stream. */
+export interface RemoteStreamFactory {
+  /**
+   * Create one independently cancellable logical stream.
+   * @param options - domain-owned opener and generation-end classification.
+   * @returns a reconnecting single-consumer stream.
+   */
+  $stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item>
+}
+
+/** Domain publication and cursor operations for one addressed journal stream. */
+export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
+  /** Diagnostic stream name used in protocol failures. */
+  readonly name: string
+  /** Cursor representing a journal with no entries. */
+  readonly emptyCursor: Cursor
+  /** Read the ordered entries carried by a page. */
+  readonly entries: (page: Page) => readonly Entry[]
+  /** Read whether an older page exists. */
+  readonly hasMore: (page: Page) => boolean
+  /** Read one entry's durable cursor. */
+  readonly cursor: (entry: Entry) => Cursor
+  /** Compare two cursors. */
+  readonly compare: (left: Cursor, right: Cursor) => number
+  /** Test whether the right cursor immediately follows the left cursor. */
+  readonly follows: (left: Cursor, right: Cursor) => boolean
+  /** Apply one complete journal-window change. */
+  readonly publish: (change: RemoteJournalChange<Page, Entry>) => void
+  /** Observe a retryable carrier loss before reconnection. */
+  readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
+  /** Publish a terminal stream, page, or protocol failure after opening. */
+  readonly failed: (error: unknown) => void
+}
+
+/**
+ * Owns follow-before-page opening, ordered live delivery, pagination, and repair.
+ *
+ * The domain retains its published window during reconnection. A replacement is
+ * published only after a tail page reaches the generation's opening cursor.
+ */
+export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = void> {
+  private readonly stream: RemoteStream<RemoteJournalFrame<Entry, Cursor>>
+  private initialRequest!: PageRequest
+  private hasInitialRequest = false
+  private resumeCursor: Cursor | undefined
+  private hasResumeCursor = false
+  private generation = 0
+  private firstCursor: Cursor | undefined
+  private lastCursor: Cursor | undefined
+  private started = false
+  private opened = false
+  private disposed = false
+  private done: Promise<void> | undefined
+  private closing: Promise<void> | undefined
+  private pendingNext: Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> | undefined
+
+  /**
+   * @param remote - Gateway factory for the reconnecting physical-generation stream.
+   * @param options - cursor algebra and domain publication sinks.
+   */
+  protected constructor(
+    remote: RemoteStreamFactory,
+    private readonly options: RemoteJournalStreamOptions<Page, Entry, Cursor>,
+  ) {
+    this.stream = remote.$stream<RemoteJournalFrame<Entry, Cursor>>({
+      name: options.name,
+      open: signal => this.follow(
+        this.hasResumeCursor ? this.resumeCursor : undefined,
+        signal,
+      ),
+      ended: accepted => accepted
+        ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`)
+        : new Error(
+          `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`,
+        ),
+      ...(options.carrierFailed === undefined
+        ? {}
+        : { carrierFailed: options.carrierFailed }),
+    })
+  }
+
+  /**
+   * Open one physical journal generation after the last accepted cursor.
+   * @param after - last accepted cursor, or `undefined` for the initial generation.
+   * @param signal - cancellation lifetime of the physical generation.
+   * @returns opening cursor followed by live entries.
+   */
+  protected abstract follow(
+    after: Cursor | undefined,
+    signal: AbortSignal,
+  ): AsyncIterable<RemoteJournalFrame<Entry, Cursor>>
+
+  /**
+   * Read one journal page through the addressed domain source.
+   * @param request - domain page request.
+   * @param signal - cancellation lifetime shared with the logical stream.
+   * @returns the requested page.
+   */
+  protected abstract readPage(request: PageRequest, signal: AbortSignal): Promise<Page>
+
+  /**
+   * Derive an unbounded-tail request from the initial page request.
+   * @param initial - request used to open the journal window.
+   * @returns request suitable for reconnect and gap repair.
+   */
+  protected abstract repairRequest(initial: PageRequest): PageRequest
+
+  /** Cancellation lifetime shared by follow and page calls. */
+  get signal(): AbortSignal {
+    return this.stream.signal
+  }
+
+  /**
+   * Establish follow before reading and publishing the initial page.
+   * @param request - initial tail-page request.
+   * @returns after the first complete window is published.
+   */
+  async open(request: PageRequest): Promise<void> {
+    if (this.started) throw new Error(`${this.options.name} already opened`)
+    this.started = true
+    this.initialRequest = request
+    this.hasInitialRequest = true
+    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`)
+      await this.replaceGeneration(request, first.value, iterator, false)
+      this.opened = true
+      this.done = this.consume(iterator)
+    } catch (error) {
+      await this.stream.dispose()
+      throw error
+    }
+  }
+
+  /**
+   * Read and prepend one older page after a successful open.
+   * @param request - domain page request bound to this stream's address.
+   * @returns after the page is applied or rejected as discontinuous.
+   */
+  async prepend(request: PageRequest): Promise<void> {
+    if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`)
+    const page = await this.readPage(request, this.stream.signal)
+    this.stream.signal.throwIfAborted()
+    const entries = this.options.entries(page)
+    this.assertPage(entries)
+    const before = this.firstCursor
+    const accepted = before === undefined
+      ? [...entries]
+      : entries.filter(entry => this.options.compare(this.options.cursor(entry), before) < 0)
+    const tail = accepted.at(-1)
+    if (tail !== undefined && before !== undefined
+      && !this.options.follows(this.options.cursor(tail), before)) {
+      this.options.publish({ type: 'prepend', page, entries: [], hasMore: false })
+      throw new Error(`${this.options.name} history page is discontinuous`)
+    }
+    const first = accepted[0]
+    if (first !== undefined) this.firstCursor = this.options.cursor(first)
+    this.options.publish({
+      type: 'prepend',
+      page,
+      entries: accepted,
+      hasMore: this.options.hasMore(page),
+    })
+  }
+
+  /** Replace the active physical generation while retaining the published window. */
+  restart(): void {
+    this.stream.restart()
+  }
+
+  /**
+   * Permanently stop follow, page requests, and the background consumer.
+   * @returns when no stream work or publication callback can still run.
+   */
+  dispose(): Promise<void> {
+    if (this.closing !== undefined) return this.closing
+    this.disposed = true
+    const done = this.done
+    const closing = (async () => {
+      await this.stream.dispose()
+      await done
+    })()
+    this.closing = closing
+    return closing
+  }
+
+  private async consume(
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+  ): Promise<void> {
+    try {
+      while (true) {
+        const next = await this.takeNext(iterator)
+        if (next.done) return
+        const item = next.value
+        if (item.generation !== this.generation) {
+          await this.replaceGeneration(this.repairPageRequest(), item, iterator, true)
+          continue
+        }
+        if (item.value.type === 'opened') {
+          throw new Error(`${this.options.name} emitted more than one opening cursor`)
+        }
+        await this.acceptEntry(item, iterator)
+      }
+    } catch (error) {
+      if (!this.disposed) this.options.failed(error)
+    }
+  }
+
+  private async replaceGeneration(
+    request: PageRequest,
+    initial: JournalStreamItem<Entry, Cursor>,
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    resumed: boolean,
+  ): Promise<void> {
+    let item = initial
+    let isResumed = resumed
+    while (true) {
+      const cursor = this.opening(item, isResumed)
+      this.setResumeCursor(cursor)
+      const superseded = await this.replaceThrough(
+        request,
+        cursor,
+        item.generation,
+        item.signal,
+        iterator,
+        [],
+      )
+      if (superseded === undefined) return
+      item = superseded
+      isResumed = true
+    }
+  }
+
+  private opening(
+    item: RemoteStreamItem<RemoteJournalFrame<Entry, Cursor>>,
+    resumed: boolean,
+  ): Cursor {
+    if (item.value.type !== 'opened') {
+      throw new Error(`${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(
+        `${this.options.name} resumed at a cursor behind the last applied entry`,
+      )
+    }
+    this.generation = item.generation
+    item.accept()
+    return cursor
+  }
+
+  private async acceptEntry(
+    item: JournalStreamItem<Entry, Cursor>,
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+  ): Promise<void> {
+    if (item.value.type !== 'entry') {
+      throw new Error(`${this.options.name} emitted more than one opening cursor`)
+    }
+    const entry = item.value.entry
+    const cursor = this.options.cursor(entry)
+    const last = this.lastCursor
+    if (last !== undefined) {
+      if (this.options.compare(cursor, last) <= 0) return
+      if (!this.options.follows(last, cursor)) {
+        const request = this.repairPageRequest()
+        const superseded = await this.replaceThrough(
+          request,
+          cursor,
+          item.generation,
+          item.signal,
+          iterator,
+          [entry],
+        )
+        if (superseded !== undefined) {
+          await this.replaceGeneration(request, superseded, iterator, true)
+        }
+        return
+      }
+    }
+    if (this.firstCursor === undefined) this.firstCursor = cursor
+    this.lastCursor = cursor
+    this.setResumeCursor(cursor)
+    this.options.publish({ type: 'append', entry })
+  }
+
+  private async replaceThrough(
+    request: PageRequest,
+    requiredCursor: Cursor,
+    generation: number,
+    signal: AbortSignal,
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    queued: Entry[],
+  ): Promise<JournalStreamItem<Entry, Cursor> | undefined> {
+    let read = await this.readPageWhileFollowing(request, generation, signal, iterator, queued)
+    if (read.type === 'superseded') return read.item
+    let page = read.page
+    let entries = this.mergeReplacement(page, queued)
+    let target = this.maxCursor(requiredCursor, queued)
+    if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) {
+      read = await this.readPageWhileFollowing(
+        this.repairPageRequest(),
+        generation,
+        signal,
+        iterator,
+        queued,
+      )
+      if (read.type === 'superseded') return read.item
+      page = read.page
+      entries = this.mergeReplacement(page, queued)
+      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`)
+    }
+    const first = entries[0]
+    this.firstCursor = first === undefined ? undefined : this.options.cursor(first)
+    this.lastCursor = this.tailCursor(entries)
+    this.setResumeCursor(this.lastCursor)
+    this.options.publish({
+      type: 'replace',
+      page,
+      entries,
+      hasMore: this.options.hasMore(page),
+    })
+    return undefined
+  }
+
+  private async readPageWhileFollowing(
+    request: PageRequest,
+    generation: number,
+    signal: AbortSignal,
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+    queued: Entry[],
+  ): Promise<
+    | { readonly type: 'page'; readonly page: Page }
+    | { readonly type: 'superseded'; readonly item: JournalStreamItem<Entry, Cursor> }
+  > {
+    const page = this.readPage(request, signal).then(
+      value => ({ type: 'page' as const, value }),
+      (error: unknown) => ({ type: 'page-error' as const, error }),
+    )
+    while (true) {
+      const pending = this.nextResult(iterator)
+      const next = pending.then(
+        value => ({ type: 'next' as const, value }),
+        (error: unknown) => ({ type: 'next-error' as const, error }),
+      )
+      const result = await Promise.race([page, next])
+      if (result.type === 'page') {
+        signal.throwIfAborted()
+        return { type: 'page', page: result.value }
+      }
+      if (result.type === 'page-error') throw result.error
+      this.releaseNext(pending)
+      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`)
+      }
+      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`)
+      }
+      queued.push(item.value.entry)
+    }
+  }
+
+  private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined {
+    const entries = [...this.options.entries(page)]
+    this.assertPage(entries)
+    const sorted = [...queued].sort((left, right) => (
+      this.options.compare(this.options.cursor(left), this.options.cursor(right))
+    ))
+    let tail = this.tailCursor(entries)
+    for (const entry of sorted) {
+      const cursor = this.options.cursor(entry)
+      if (this.options.compare(cursor, tail) <= 0) continue
+      if (!this.options.follows(tail, cursor)) return undefined
+      entries.push(entry)
+      tail = cursor
+    }
+    return entries
+  }
+
+  private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor {
+    let result = cursor
+    for (const entry of entries) {
+      const candidate = this.options.cursor(entry)
+      if (this.options.compare(candidate, result) > 0) result = candidate
+    }
+    return result
+  }
+
+  private nextResult(
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+  ): Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> {
+    this.pendingNext ??= iterator.next()
+    return this.pendingNext
+  }
+
+  private async takeNext(
+    iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
+  ): Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> {
+    const pending = this.nextResult(iterator)
+    try {
+      return await pending
+    } finally {
+      this.releaseNext(pending)
+    }
+  }
+
+  private releaseNext(pending: Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>>): void {
+    if (this.pendingNext === pending) this.pendingNext = undefined
+  }
+
+  private repairPageRequest(): PageRequest {
+    if (!this.hasInitialRequest) throw new Error(`${this.options.name} has no initial page request`)
+    return this.repairRequest(this.initialRequest)
+  }
+
+  private setResumeCursor(cursor: Cursor): void {
+    this.resumeCursor = cursor
+    this.hasResumeCursor = true
+  }
+
+  private tailCursor(entries: readonly Entry[]): Cursor {
+    const tail = entries.at(-1)
+    return tail === undefined ? this.options.emptyCursor : this.options.cursor(tail)
+  }
+
+  private assertPage(entries: readonly Entry[]): void {
+    for (let index = 1; index < entries.length; index++) {
+      const previous = entries[index - 1]
+      const entry = entries[index]
+      if (previous === undefined || entry === undefined) continue
+      if (!this.options.follows(this.options.cursor(previous), this.options.cursor(entry))) {
+        throw new Error(`${this.options.name} page contains discontinuous entries`)
+      }
+    }
+  }
+}

+ 339 - 0
packages/api/gateway/src/client/remote-events.ts

@@ -0,0 +1,339 @@
+/** Client owner for forwarded Remote Event subscriptions and deliveries. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type {
+  ConnectionGenerationSource,
+  ConnectionHandle,
+} from '@deepseek-ai/dsh-client-connection/client'
+import type {
+  TypertClientEventListener,
+  TypertRemoteEvent,
+} from '@deepseek-ai/dsh-typert-protocol'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import {
+  REMOTE_EVENT_RESULT_ENDPOINT,
+  REMOTE_EVENT_STREAM_ENDPOINT,
+  REMOTE_EVENT_STREAM_PAYLOAD,
+  isRemoteEventAgentId,
+  isRemoteEventClientId,
+  isRemoteEventId,
+  isRemoteJsonValue,
+  projectRemoteEventRejection,
+  type RemoteEventClientId,
+  type RemoteEventDownlinkFrame,
+  type RemoteEventEmitFrame,
+  type RemoteEventInvocationFrame,
+  type RemoteEventResult,
+} from '../stream-protocol.ts'
+
+/** Open the Gateway-internal forwarded-event stream on the selected carrier. */
+export type RemoteEventStreamOpener = (
+  endpoint: string,
+  payload: unknown,
+  signal: AbortSignal,
+) => AsyncIterable<unknown>
+
+/** One subscribed listener after its event-specific signature is erased. */
+type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown
+
+/** Untyped access used only for instance-private Cordis event keys. */
+interface PrivateEventContext {
+  on(name: string, listener: RemoteEventListener): () => boolean
+  parallel(name: string, ...args: unknown[]): Promise<void>
+  waterfall(
+    thisArg: Context,
+    name: string,
+    request: Readonly<Record<string, unknown>>,
+    next: () => Promise<symbol>,
+  ): unknown
+}
+
+/** Transport outcome after one Client listener chain either claims or delegates. */
+type RemoteEventReplyOutcome =
+  | { readonly kind: 'result'; readonly value: unknown }
+  | { readonly kind: 'next' }
+  | { readonly kind: 'rejected'; readonly error: ReturnType<typeof projectRemoteEventRejection> }
+
+/** Private end-of-chain marker that cannot collide with a JSON listener result. */
+const REMOTE_EVENT_NEXT = Symbol('api-gateway.remote-event.next')
+
+/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */
+export class ClientRemoteEvents {
+  private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/`
+  private readonly unregisterGeneration: () => void
+  private activeGeneration: Promise<void> | undefined
+
+  /**
+   * @param ownerCtx - Client Gateway root used for Agent Context resolution.
+   * @param connection - Connection carrier used for HTTP result calls.
+   * @param openStream - selected in-process or WebSocket stream opener.
+   */
+  constructor(
+    private readonly ownerCtx: Context,
+    private readonly connection: ConnectionHandle,
+    private readonly openStream: RemoteEventStreamOpener,
+  ) {
+    this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration)
+  }
+
+  /**
+   * Register one typed Remote Event listener in its calling fiber.
+   * @param callerCtx - fiber Context owning the registration.
+   * @param event - selected forwarded event.
+   * @param listener - listener derived from that event's declaration.
+   * @returns disposer for this exact registration.
+   */
+  subscribe<Event extends TypertRemoteEvent>(
+    callerCtx: Context,
+    event: Event,
+    listener: TypertClientEventListener<Event>,
+  ): () => void {
+    const dispose = privateEvents(callerCtx).on(
+      this.eventKey(event),
+      listener as unknown as RemoteEventListener,
+    )
+    return () => { dispose() }
+  }
+
+  /** Withdraw the generation source and wait for active listener work to quiesce. */
+  async dispose(): Promise<void> {
+    this.unregisterGeneration()
+    await Promise.allSettled([this.activeGeneration])
+  }
+
+  /** Track the current generation so plugin disposal waits for listener work to stop. */
+  private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => {
+    const tracked = this.pumpEvents(signal, ready).finally(() => {
+      if (this.activeGeneration === tracked) this.activeGeneration = undefined
+    })
+    this.activeGeneration = tracked
+    return tracked
+  }
+
+  /** Deliver one notification through Cordis while containing listener failures. */
+  private deliver(frame: RemoteEventEmitFrame): void {
+    void privateEvents(this.ownerCtx)
+      .parallel(this.eventKey(frame.event), ...frame.args)
+      .catch((error: unknown) => { this.reportError(frame.event, error) })
+  }
+
+  /** Run one Connection generation over the forwarded-event logical stream. */
+  private async pumpEvents(signal: AbortSignal, ready: () => void): Promise<void> {
+    let clientId: RemoteEventClientId | undefined
+    const failed = new AbortController()
+    const generationSignal = AbortSignal.any([signal, failed.signal])
+    const active = new Map<string, AbortController>()
+    const tasks = new Set<Promise<void>>()
+    const source = this.openStream(
+      REMOTE_EVENT_STREAM_ENDPOINT,
+      REMOTE_EVENT_STREAM_PAYLOAD,
+      generationSignal,
+    )
+    let streamFailed = false
+    let streamError: unknown
+    try {
+      for await (const value of source) {
+        if (clientId === undefined) {
+          clientId = parseRemoteEventReady(value)
+          ready()
+          continue
+        }
+        const frame = parseRemoteEventFrame(value)
+        if (frame.type === 'cancel') {
+          active.get(frame.eventId)?.abort(new Error('client api: Remote event was cancelled by the Host'))
+          continue
+        }
+        if (frame.type === 'emit') {
+          this.deliver(frame)
+          continue
+        }
+        const controller = new AbortController()
+        active.set(frame.eventId, controller)
+        const deliverySignal = AbortSignal.any([generationSignal, controller.signal])
+        const task = this.answer(frame, clientId, deliverySignal)
+          .catch((error: unknown) => {
+            if (!deliverySignal.aborted) failed.abort(error)
+          })
+          .finally(() => {
+            active.delete(frame.eventId)
+            tasks.delete(task)
+          })
+        tasks.add(task)
+      }
+    } catch (error) {
+      streamFailed = true
+      streamError = error
+    } finally {
+      for (const controller of active.values()) {
+        controller.abort(new Error('client api: Remote event generation ended'))
+      }
+      await Promise.allSettled(tasks)
+    }
+    if (failed.signal.aborted) {
+      throw toError(failed.signal.reason, 'client api: Remote event result delivery failed')
+    }
+    if (signal.aborted) return
+    if (streamFailed) throw streamError
+    throw new Error('client api: forwarded Remote event stream ended unexpectedly')
+  }
+
+  private async answer(
+    frame: RemoteEventInvocationFrame,
+    clientId: RemoteEventClientId,
+    signal: AbortSignal,
+  ): Promise<void> {
+    const adapter = this.ownerCtx.typert.contexts.getClient('agent')
+    let target: Context | undefined
+    try {
+      target = adapter?.resolve(frame.agentId)
+    } catch (error) {
+      this.reportError(frame.event, error)
+    }
+    let outcome: RemoteEventReplyOutcome = { kind: 'next' }
+    if (target !== undefined) {
+      try {
+        outcome = await this.dispatchWaterfall(target, frame, signal)
+      } catch (error) {
+        if (signal.aborted) return
+        outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) }
+      }
+    }
+    if (signal.aborted) return
+    const result: RemoteEventResult = {
+      clientId,
+      eventId: frame.eventId,
+      outcome: outcome.kind === 'result' && outcome.value === undefined
+        ? { kind: 'result' }
+        : outcome,
+    }
+    const response = await this.connection.rpc.call(
+      '/api',
+      REMOTE_EVENT_RESULT_ENDPOINT,
+      { args: result },
+      signal,
+    )
+    if (!response.ok) throw new Error(response.error.message)
+  }
+
+  private async dispatchWaterfall(
+    target: Context,
+    frame: RemoteEventInvocationFrame,
+    signal: AbortSignal,
+  ): Promise<RemoteEventReplyOutcome> {
+    const request = {
+      ...frame.request,
+      agent: target,
+      signal,
+    }
+    const value = await abortable(
+      Promise.resolve(privateEvents(target).waterfall(
+        target,
+        this.eventKey(frame.event),
+        request,
+        () => Promise.resolve(REMOTE_EVENT_NEXT),
+      )),
+      signal,
+    )
+    if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) {
+      throw new TypeError('Remote event listener result is not lossless JSON data')
+    }
+    return value === REMOTE_EVENT_NEXT
+      ? { kind: 'next' }
+      : { kind: 'result', value }
+  }
+
+  private eventKey(event: string): string {
+    return `${this.eventPrefix}${event}`
+  }
+
+  private reportError(event: string, error: unknown): void {
+    console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
+  }
+}
+
+/** Validate and return the Client identity from one generation's opening item. */
+function parseRemoteEventReady(value: unknown): RemoteEventClientId {
+  if (!isRemoteEventRecord(value)
+    || !hasExactRemoteEventKeys(value, ['type', 'clientId'])
+    || value.type !== 'ready'
+    || !isRemoteEventClientId(value.clientId)) {
+    throw new TypeError('client api: forwarded Remote event stream did not begin with ready')
+  }
+  return value.clientId
+}
+
+/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */
+function parseRemoteEventFrame(value: unknown): Exclude<RemoteEventDownlinkFrame, { type: 'ready' }> {
+  if (!isRemoteEventRecord(value)) invalidRemoteEventFrame()
+  if (value.type === 'cancel'
+    && hasExactRemoteEventKeys(value, ['type', 'eventId'])
+    && isRemoteEventId(value.eventId)) {
+    return { type: 'cancel', eventId: value.eventId }
+  }
+  if (value.type === 'emit'
+    && hasExactRemoteEventKeys(value, ['type', 'event', 'args'])
+    && validRemoteEventName(value.event)
+    && Array.isArray(value.args)
+    && isRemoteJsonValue(value.args)) {
+    return { type: 'emit', event: value.event, args: value.args }
+  }
+  if (value.type === 'waterfall'
+    && hasExactRemoteEventKeys(value, ['type', 'event', 'eventId', 'agentId', 'request'])
+    && validRemoteEventName(value.event)
+    && isRemoteEventId(value.eventId)
+    && isRemoteEventAgentId(value.agentId)
+    && isRemoteEventRecord(value.request)
+    && !Object.hasOwn(value.request, 'agent')
+    && !Object.hasOwn(value.request, 'signal')
+    && isRemoteJsonValue(value.request)) {
+    return {
+      type: 'waterfall',
+      event: value.event,
+      eventId: value.eventId,
+      agentId: value.agentId,
+      request: value.request,
+    }
+  }
+  invalidRemoteEventFrame()
+}
+
+function isRemoteEventRecord(value: unknown): value is Record<string, unknown> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const prototype: unknown = Object.getPrototypeOf(value)
+  return prototype === Object.prototype || prototype === null
+}
+
+function hasExactRemoteEventKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
+  const ownKeys = Reflect.ownKeys(value)
+  return ownKeys.length === keys.length && keys.every(key => Object.hasOwn(value, key))
+}
+
+function validRemoteEventName(value: unknown): value is string {
+  return typeof value === 'string' && value.length > 0
+}
+
+function invalidRemoteEventFrame(): never {
+  throw new TypeError('client api: invalid forwarded Remote event frame')
+}
+
+/** Race listener completion against its delivery lifetime. */
+async function abortable<T>(value: T | PromiseLike<T>, signal: AbortSignal): Promise<T> {
+  signal.throwIfAborted()
+  let rejectAbort: ((reason: unknown) => void) | undefined
+  const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
+  const onAbort = (): void => { rejectAbort?.(signal.reason) }
+  signal.addEventListener('abort', onAbort, { once: true })
+  try {
+    return await Promise.race([Promise.resolve(value), aborted])
+  } finally {
+    signal.removeEventListener('abort', onAbort)
+  }
+}
+
+function privateEvents(ctx: Context): PrivateEventContext {
+  return ctx
+}
+
+function toError(reason: unknown, message: string): Error {
+  return reason instanceof Error ? reason : new Error(message, { cause: reason })
+}

+ 210 - 0
packages/api/gateway/src/client/remote-stream.ts

@@ -0,0 +1,210 @@
+/** Reconnecting lifecycle for one single-consumer Remote stream. */
+
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { RemoteStreamCarrierError } from './stream-client.ts'
+
+/** One item annotated with the physical Remote-stream generation that delivered it. */
+export interface RemoteStreamItem<Item> {
+  /** Monotone physical generation number within this logical stream. */
+  readonly generation: number
+  /** Decoded item yielded by the generated Remote method. */
+  readonly value: Item
+  /** Cancellation lifetime of the generation that delivered this item. */
+  readonly signal: AbortSignal
+  /** Mark this generation's opening baseline or cursor as accepted. */
+  accept(): void
+}
+
+/** Domain-owned operations used by {@link RemoteStream}. */
+export interface RemoteStreamOptions<Item> {
+  /** Diagnostic owner name used for cancellation failures. */
+  readonly name: string
+  /** Open one physical generation of the logical stream. */
+  readonly open: (signal: AbortSignal) => AsyncIterable<Item>
+  /** Classify a normal generation end after or before its opening item was accepted. */
+  readonly ended: (accepted: boolean) => Error
+  /** Observe a retryable carrier loss before the supervisor waits or reopens. */
+  readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
+}
+
+/**
+ * Reopens one logical Remote stream across carrier generations.
+ *
+ * The Gateway owns physical retry timing, cancellation, and replacement. The
+ * domain consumer owns its opening item and every later item, and calls
+ * {@link RemoteStreamItem.accept} only after validating the opening
+ * baseline or cursor.
+ */
+export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>> {
+  private readonly lifetime = new AbortController()
+  private generationAbort: AbortController | undefined
+  private iterator: AsyncGenerator<RemoteStreamItem<Item>> | undefined
+  private closing: Promise<void> | undefined
+  private revision = 0
+  private taken = false
+
+  /**
+   * @param connection - observable Host generation source used to pace retries.
+   * @param options - domain stream opener, end classification, and diagnostics.
+   */
+  constructor(
+    private readonly connection: Pick<ConnectionHandle, 'hostDescription'>,
+    private readonly options: RemoteStreamOptions<Item>,
+  ) {}
+
+  /** Cancellation lifetime shared by the stream and sibling page requests. */
+  get signal(): AbortSignal {
+    return this.lifetime.signal
+  }
+
+  /** Interrupt the current generation and immediately request a replacement. */
+  restart(): void {
+    if (this.lifetime.signal.aborted) return
+    this.revision++
+    this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`))
+  }
+
+  /**
+   * Permanently stop this stream and wait for its iterator to close.
+   * @returns when the active generation and consumer iterator are quiescent.
+   */
+  dispose(): Promise<void> {
+    if (this.closing !== undefined) return this.closing
+    if (!this.lifetime.signal.aborted) {
+      const reason = new Error(`${this.options.name} disposed`)
+      this.lifetime.abort(reason)
+      this.generationAbort?.abort(reason)
+    }
+    const iterator = this.iterator
+    if (iterator === undefined) return Promise.resolve()
+    const closing = closeRemoteStreamIterator(iterator)
+    this.closing = closing
+    return closing
+  }
+
+  /** @inheritdoc */
+  [Symbol.asyncIterator](): AsyncIterator<RemoteStreamItem<Item>> {
+    if (this.taken) throw new Error(`${this.options.name} already has a consumer`)
+    this.taken = true
+    const iterator = this.read()
+    this.iterator = iterator
+    return iterator
+  }
+
+  private async * read(): AsyncGenerator<RemoteStreamItem<Item>> {
+    let attempt = 0
+    let generation = 0
+    let observedRevision = this.revision
+    try {
+      while (!isAborted(this.lifetime.signal)) {
+        if (observedRevision !== this.revision) {
+          observedRevision = this.revision
+          attempt = 0
+        }
+        const revision = this.revision
+        const generationAbort = new AbortController()
+        this.generationAbort = generationAbort
+        const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal])
+        const generationId = ++generation
+        let accepted = false
+        try {
+          for await (const value of this.options.open(signal)) {
+            if (isAborted(this.lifetime.signal)) return
+            if (revision !== this.revision) break
+            yield {
+              generation: generationId,
+              value,
+              signal,
+              accept: () => {
+                if (this.generationAbort !== generationAbort || revision !== this.revision) return
+                accepted = true
+                attempt = 0
+              },
+            }
+          }
+          if (isAborted(this.lifetime.signal)) return
+          if (revision !== this.revision) continue
+          throw this.options.ended(accepted)
+        } catch (error) {
+          if (isAborted(this.lifetime.signal)) return
+          if (revision !== this.revision) continue
+          if (!(error instanceof RemoteStreamCarrierError)) throw error
+          this.options.carrierFailed?.(error)
+          if (revision !== this.revision) continue
+          attempt++
+          try {
+            await waitForRemoteStreamRetry(this.connection, error, attempt, signal)
+          } catch (retryError) {
+            if (isAborted(this.lifetime.signal)) return
+            if (revision !== this.revision) continue
+            throw retryError
+          }
+        } finally {
+          this.generationAbort = undefined
+          if (!generationAbort.signal.aborted) {
+            generationAbort.abort(new Error(`${this.options.name} generation ended`))
+          }
+        }
+      }
+    } finally {
+      if (!this.lifetime.signal.aborted) {
+        this.lifetime.abort(new Error(`${this.options.name} consumer closed`))
+      }
+      this.generationAbort?.abort(this.lifetime.signal.reason)
+      this.generationAbort = undefined
+    }
+  }
+}
+
+async function waitForRemoteStreamRetry(
+  connection: Pick<ConnectionHandle, 'hostDescription'>,
+  error: RemoteStreamCarrierError,
+  attempt: number,
+  signal: AbortSignal,
+): Promise<void> {
+  signal.throwIfAborted()
+  if (connection.hostDescription.getSnapshot() !== undefined) {
+    if (attempt === 1) return
+    throw error
+  }
+  await new Promise<void>((resolve, reject) => {
+    const subscription: {
+      dispose?: () => void
+      finished: boolean
+    } = { finished: false }
+    const finish = (failure?: Error): void => {
+      if (subscription.finished) return
+      subscription.finished = true
+      subscription.dispose?.()
+      signal.removeEventListener('abort', aborted)
+      if (failure === undefined) resolve()
+      else reject(failure)
+    }
+    const inspect = (): void => {
+      if (connection.hostDescription.getSnapshot() !== undefined) finish()
+    }
+    const aborted = (): void => {
+      finish(new Error('Remote stream retry aborted', { cause: signal.reason }))
+    }
+    const dispose = connection.hostDescription.subscribe(inspect)
+    subscription.dispose = dispose
+    if (subscription.finished) dispose()
+    signal.addEventListener('abort', aborted, { once: true })
+    if (signal.aborted) aborted()
+    else inspect()
+  })
+}
+
+function isAborted(signal: AbortSignal): boolean {
+  return signal.aborted
+}
+
+async function closeRemoteStreamIterator<Item>(
+  iterator: AsyncIterator<RemoteStreamItem<Item>>,
+): Promise<void> {
+  try {
+    await iterator.return?.()
+  } catch {
+    // The disposed logical stream has no remaining consumer for cancellation failures.
+  }
+}

+ 88 - 0
packages/api/gateway/src/client/snapshot-stream.ts

@@ -0,0 +1,88 @@
+/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */
+
+import type { RemoteStream } from './remote-stream.ts'
+
+/** Domain operations for one snapshot stream. */
+export interface RemoteSnapshotStreamOptions<Snapshot, Delta> {
+  /** Diagnostic stream name used in protocol failures. */
+  readonly name: string
+  /** Distinguish the opening snapshot from later deltas. */
+  readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot
+  /** Atomically replace the domain model from a complete snapshot. */
+  readonly replace: (snapshot: Snapshot) => void
+  /** Apply one incremental update after the generation snapshot. */
+  readonly update: (delta: Delta) => void
+  /** Publish a terminal business or protocol failure. */
+  readonly failed: (error: unknown) => void
+}
+
+/**
+ * Consumes generations that each contain exactly one opening snapshot followed by deltas.
+ *
+ * The previous domain snapshot remains published while the underlying stream retries. A
+ * replacement becomes accepted only after the domain owner applies it successfully.
+ */
+export class RemoteSnapshotStream<Snapshot, Delta> {
+  private started = false
+  private disposed = false
+  private done: Promise<void> | undefined
+
+  /**
+   * @param stream - reconnecting physical-generation stream.
+   * @param options - frame discriminator and domain state destinations.
+   */
+  constructor(
+    private readonly stream: RemoteStream<Snapshot | Delta>,
+    private readonly options: RemoteSnapshotStreamOptions<Snapshot, Delta>,
+  ) {}
+
+  /** Start the single consumer; repeated calls are inert. */
+  start(): void {
+    if (this.started) return
+    this.started = true
+    this.done = this.consume()
+  }
+
+  /** Replace the active physical generation without discarding the published snapshot. */
+  restart(): void {
+    this.stream.restart()
+  }
+
+  /**
+   * Permanently stop the stream and wait for its consumer to become quiescent.
+   * @returns when no generation or callback can still run.
+   */
+  async dispose(): Promise<void> {
+    this.disposed = true
+    await this.stream.dispose()
+    await this.done
+  }
+
+  private async consume(): Promise<void> {
+    let generation = 0
+    let snapshotSeen = false
+    try {
+      for await (const item of this.stream) {
+        if (item.generation !== generation) {
+          generation = item.generation
+          snapshotSeen = false
+        }
+        if (this.options.isSnapshot(item.value)) {
+          if (snapshotSeen) {
+            throw new Error(`${this.options.name} emitted more than one opening snapshot`)
+          }
+          this.options.replace(item.value)
+          snapshotSeen = true
+          item.accept()
+          continue
+        }
+        if (!snapshotSeen) {
+          throw new Error(`${this.options.name} emitted an update before its opening snapshot`)
+        }
+        this.options.update(item.value)
+      }
+    } catch (error) {
+      if (!this.disposed) this.options.failed(error)
+    }
+  }
+}

+ 348 - 0
packages/api/gateway/src/client/stream-client.ts

@@ -0,0 +1,348 @@
+/** Browser owner for the Gateway multiplexed Remote stream socket. */
+
+import {
+  parseRemoteStreamServerMessage,
+  REMOTE_STREAM_MUX_PATH,
+  type RemoteStreamClientMessage,
+  type RemoteStreamServerMessage,
+} from '../stream-protocol.ts'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
+
+const INTERNAL_BASE = 'http://dsh.internal'
+const RECONNECT_BASE_MS = 500
+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 {
+  /**
+   * @param message - physical carrier failure description.
+   * @param options - optional causal error.
+   */
+  constructor(message: string, options?: ErrorOptions) {
+    super(message, options)
+    this.name = 'RemoteStreamCarrierError'
+  }
+}
+
+interface SocketWaiter {
+  resolve(socket: WebSocket): void
+  reject(error: unknown): void
+}
+
+/** Keep one physical WebSocket and share it among independently cancellable Remote streams. */
+export class RemoteStreamMuxClient {
+  private socket: WebSocket | undefined
+  private cancelCandidate: ((error: Error) => void) | undefined
+  private keepAlive: Promise<void> | undefined
+  private keepAliveAbort: AbortController | undefined
+  private readonly streams = new Map<string, StreamInbox>()
+  private readonly waiters = new Set<SocketWaiter>()
+  private running = false
+  private disposed = false
+
+  /** Start the persistent physical connection; repeated calls are inert. */
+  start(): void {
+    if (this.running || this.disposed) return
+    this.running = true
+    this.maintain()
+  }
+
+  /**
+   * Open one logical stream on the persistent physical connection.
+   * @param endpoint - Typert Remote stream endpoint.
+   * @param payload - endpoint request encoded on the wire.
+   * @param signal - cancellation for this logical stream.
+   * @returns Host items until completion, cancellation, or failure.
+   */
+  async *open(
+    endpoint: string,
+    payload: unknown,
+    signal: AbortSignal,
+  ): AsyncGenerator {
+    this.start()
+    signal.throwIfAborted()
+    const streamId = randomUUID()
+    const inbox = new StreamInbox()
+    let carrier: WebSocket | undefined
+    let opened = false
+    let terminal = false
+    const abort = (): void => { inbox.fail(signal.reason) }
+    signal.addEventListener('abort', abort, { once: true })
+    try {
+      const socket = await this.waitForSocket(signal)
+      signal.throwIfAborted()
+      carrier = socket
+      this.streams.set(streamId, inbox)
+      this.send(socket, { type: 'open', streamId, endpoint, payload })
+      opened = true
+      while (true) {
+        const frame = await inbox.next()
+        signal.throwIfAborted()
+        if (frame.type === 'item') {
+          yield frame.value
+          continue
+        }
+        terminal = true
+        if (frame.type === 'error') {
+          throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details)
+        }
+        return
+      }
+    } finally {
+      signal.removeEventListener('abort', abort)
+      this.streams.delete(streamId)
+      if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) {
+        this.send(carrier, { type: 'cancel', streamId })
+      }
+    }
+  }
+
+  /**
+   * Permanently stop reconnecting, close the physical socket, and fail every active logical stream.
+   * @returns once the background connection loop has stopped.
+   */
+  async close(): Promise<void> {
+    if (!this.disposed) {
+      this.disposed = true
+      this.running = false
+      const error = new Error('api gateway: Remote stream client disposed')
+      this.keepAliveAbort?.abort(error)
+      this.keepAliveAbort = undefined
+      this.failAll(error)
+      for (const waiter of [...this.waiters]) waiter.reject(error)
+      this.cancelCandidate?.(error)
+      const socket = this.socket
+      this.socket = undefined
+      socket?.close(1000, 'disposed')
+    }
+    await this.keepAlive
+  }
+
+  private connect(): Promise<WebSocket> {
+    const socket = new WebSocket(remoteStreamUrl())
+    const connecting = new Promise<WebSocket>((resolve, reject) => {
+      let settled = false
+      const rejectCandidate = (error: Error): void => {
+        settled = true
+        socket.removeEventListener('open', opened)
+        socket.removeEventListener('error', failed)
+        socket.removeEventListener('message', received)
+        socket.removeEventListener('close', closed)
+        this.cancelCandidate = undefined
+        socket.close()
+        reject(error)
+      }
+      const opened = (): void => {
+        settled = true
+        this.cancelCandidate = undefined
+        this.socket = socket
+        for (const waiter of [...this.waiters]) waiter.resolve(socket)
+        resolve(socket)
+      }
+      const failed = (): void => {
+        if (!settled) {
+          rejectCandidate(new RemoteStreamCarrierError(
+            'api gateway: Remote stream WebSocket failed to open',
+          ))
+          return
+        }
+        const error = new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed')
+        this.lost(socket, error)
+        socket.close()
+      }
+      const closed = (): void => {
+        if (!settled) {
+          rejectCandidate(new RemoteStreamCarrierError(
+            'api gateway: Remote stream WebSocket closed before opening',
+          ))
+          return
+        }
+        this.lost(socket)
+      }
+      const received = (event: MessageEvent): void => { this.receive(socket, event.data) }
+      this.cancelCandidate = rejectCandidate
+      socket.addEventListener('open', opened, { once: true })
+      socket.addEventListener('error', failed, { once: true })
+      socket.addEventListener('message', received)
+      socket.addEventListener('close', closed, { once: true })
+    })
+    return connecting
+  }
+
+  private waitForSocket(signal: AbortSignal): Promise<WebSocket> {
+    signal.throwIfAborted()
+    if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket)
+    if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed'))
+    this.start()
+    return new Promise((resolve, reject) => {
+      const aborted = (): void => { waiter.reject(signal.reason) }
+      const cleanup = (): void => {
+        this.waiters.delete(waiter)
+        signal.removeEventListener('abort', aborted)
+      }
+      const waiter: SocketWaiter = {
+        resolve: (socket) => {
+          cleanup()
+          resolve(socket)
+        },
+        reject: (error) => {
+          cleanup()
+          // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel.
+          // oxlint-disable-next-line typescript/prefer-promise-reject-errors
+          reject(error)
+        },
+      }
+      this.waiters.add(waiter)
+      signal.addEventListener('abort', aborted, { once: true })
+    })
+  }
+
+  private receive(socket: WebSocket, data: unknown): void {
+    if (socket !== this.socket) return
+    try {
+      if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages')
+      const frame = parseRemoteStreamServerMessage(data)
+      this.streams.get(frame.streamId)?.push(frame)
+    } catch (error) {
+      const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error })
+      this.failAll(failure)
+      this.lost(socket, failure)
+      socket.close(4002, 'invalid Remote stream frame')
+    }
+  }
+
+  private lost(
+    socket: WebSocket,
+    error: RemoteStreamCarrierError = new RemoteStreamCarrierError(
+      'api gateway: Remote stream WebSocket closed',
+    ),
+  ): void {
+    if (this.socket !== socket) return
+    this.socket = undefined
+    this.failAll(error)
+    this.maintain(error)
+  }
+
+  private maintain(previousFailure?: Error): void {
+    if (!this.running) return
+    if (this.keepAlive !== undefined) {
+      void this.keepAlive.then(() => { this.maintain(previousFailure) })
+      return
+    }
+    const abort = new AbortController()
+    this.keepAliveAbort = abort
+    const task = this.reconnect(abort.signal, previousFailure)
+    this.keepAlive = task
+    void task.then(() => {
+      this.keepAlive = undefined
+      this.keepAliveAbort = undefined
+    })
+  }
+
+  private async reconnect(signal: AbortSignal, previousFailure?: Error): Promise<void> {
+    let attempt = 0
+    let failure = previousFailure
+    while (this.isRunning(signal) && this.socket?.readyState !== WebSocket.OPEN) {
+      if (failure !== undefined) {
+        attempt += 1
+        console.warn(`[api-gateway] Remote stream connection unavailable, retry #${String(attempt)}`, failure)
+        await sleep(backoffDelay(attempt), signal)
+        if (!this.isRunning(signal)) return
+      }
+      try {
+        await this.connect()
+        return
+      } catch (error) {
+        if (!this.isRunning(signal)) return
+        failure = error as Error
+      }
+    }
+  }
+
+  private isRunning(signal: AbortSignal): boolean {
+    return this.running && !signal.aborted
+  }
+
+  private failAll(error: unknown): void {
+    for (const stream of this.streams.values()) stream.fail(error)
+  }
+
+  private send(socket: WebSocket, message: RemoteStreamClientMessage): void {
+    socket.send(JSON.stringify(message))
+  }
+}
+
+function backoffDelay(attempt: number): number {
+  const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * RECONNECT_FACTOR ** Math.max(0, attempt - 1))
+  return cap / 2 + Math.random() * (cap / 2)
+}
+
+function sleep(ms: number, signal: AbortSignal): Promise<void> {
+  return new Promise((resolve) => {
+    const timer = setTimeout(done, ms)
+    signal.addEventListener('abort', done, { once: true })
+    function done(): void {
+      clearTimeout(timer)
+      signal.removeEventListener('abort', done)
+      resolve()
+    }
+  })
+}
+
+class StreamInbox {
+  private readonly frames: RemoteStreamServerMessage[] = []
+  private wake: (() => void) | undefined
+  private failure: Error | undefined
+
+  push(frame: RemoteStreamServerMessage): void {
+    if (this.failure !== undefined) return
+    this.frames.push(frame)
+    this.wake?.()
+    this.wake = undefined
+  }
+
+  fail(error: unknown): void {
+    if (this.failure !== undefined) return
+    this.failure = error instanceof Error ? error : new Error(String(error), { cause: error })
+    this.frames.length = 0
+    this.wake?.()
+    this.wake = undefined
+  }
+
+  async next(): Promise<RemoteStreamServerMessage> {
+    while (this.frames.length === 0) {
+      if (this.failure !== undefined) throw this.failure
+      await new Promise<void>((resolve) => { this.wake = resolve })
+    }
+    return this.frames.shift() as RemoteStreamServerMessage
+  }
+}
+
+function remoteStreamUrl(): string {
+  const location = (globalThis as { location?: { origin?: string } }).location
+  const base = location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
+  const url = new URL(REMOTE_STREAM_MUX_PATH, base)
+  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+  return url.href
+}

+ 556 - 44
packages/api/gateway/src/index.ts

@@ -1,14 +1,18 @@
 /**
 /**
  * Live Typert Remote dispatch over Cordis Services and registered providers.
  * Live Typert Remote dispatch over Cordis Services and registered providers.
- * Transport, request correlation, and response envelopes belong to Connection.
+ * Unary transport and response envelopes belong to Connection; live Remote
+ * streams use the Gateway-owned WebSocket mux.
  * @module @deepseek-ai/dsh-api-gateway
  * @module @deepseek-ai/dsh-api-gateway
  */
  */
 
 
+import { randomUUID } from 'node:crypto'
 import { Context, Service, symbols } from '@deepseek-ai/cordis'
 import { Context, Service, symbols } from '@deepseek-ai/cordis'
 import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
 import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
+import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
 import {
 import {
   remoteMethods,
   remoteMethods,
   TypertLookupFailure,
   TypertLookupFailure,
+  TypertRemoteFailure,
   type InvocationDescriptor,
   type InvocationDescriptor,
   type InvocationParameterDescriptor,
   type InvocationParameterDescriptor,
   type TypertCodec,
   type TypertCodec,
@@ -18,12 +22,47 @@ import type {
   InvokeRemoteRequest,
   InvokeRemoteRequest,
   TypertGateway,
   TypertGateway,
   TypertGatewayErrorCode,
   TypertGatewayErrorCode,
+  TypertGatewayWireStream,
+  TypertRemoteEventDispatch,
+  TypertRemoteEventFrame,
+  TypertRemoteEventInvocation,
+  TypertRemoteEventOutcome,
+  TypertRemoteEventSource,
 } from './types.ts'
 } from './types.ts'
+import {
+  RemoteStreamMuxServer,
+  rejectRemoteStreamUpgrade,
+} from './stream-server.ts'
+import {
+  REMOTE_EVENT_STREAM_ENDPOINT,
+  REMOTE_EVENT_STREAM_READY,
+  REMOTE_EVENT_RESULT_ENDPOINT,
+  REMOTE_STREAM_MUX_PATH,
+  isRemoteEventAgentId,
+  isRemoteJsonValue,
+  parseRemoteEventResult,
+  projectRemoteEventRequest,
+  restoreRemoteEventRejection,
+  type RemoteEventCancellationFrame,
+  type RemoteEventClientId,
+  type RemoteEventEmitFrame,
+  type RemoteEventId,
+  type RemoteEventInvocationFrame,
+  type RemoteEventReadyFrame,
+  type RemoteStreamFailure,
+} from './stream-protocol.ts'
 
 
 export type {
 export type {
   InvokeRemoteRequest,
   InvokeRemoteRequest,
   TypertGateway,
   TypertGateway,
   TypertGatewayErrorCode,
   TypertGatewayErrorCode,
+  TypertGatewayWireStream,
+  TypertRemoteEventContext,
+  TypertRemoteEventDispatch,
+  TypertRemoteEventFrame,
+  TypertRemoteEventInvocation,
+  TypertRemoteEventOutcome,
+  TypertRemoteEventSource,
 } from './types.ts'
 } from './types.ts'
 
 
 interface GatewayErrorOptions {
 interface GatewayErrorOptions {
@@ -36,6 +75,34 @@ interface ResolvedBinding {
   readonly original: object
   readonly original: object
 }
 }
 
 
+interface PreparedInvocation {
+  readonly endpoint: string
+  readonly descriptor: InvocationDescriptor
+  readonly receiver: object
+  readonly args: readonly unknown[]
+  readonly method: (...args: never[]) => unknown
+}
+
+interface RegisteredRemoteEventSource {
+  readonly lifetime: AbortController
+  readonly done: Promise<void>
+}
+
+interface RemoteEventClient {
+  readonly id: RemoteEventClientId
+  readonly queue: RemoteEventQueue
+  readonly deliveries: Map<RemoteEventId, PendingRemoteEvent>
+}
+
+interface PendingRemoteEvent {
+  readonly id: RemoteEventId
+  readonly source: TypertRemoteEventInvocation
+  readonly frame: RemoteEventInvocationFrame
+  readonly deliveries: Set<RemoteEventClient>
+  releaseContext: () => void
+  releaseSignal: () => void
+}
+
 type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
 type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
 type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
 type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
 const NEVER_ABORTED_SIGNAL = new AbortController().signal
 const NEVER_ABORTED_SIGNAL = new AbortController().signal
@@ -90,7 +157,16 @@ class RemoteInvocationCancelled extends Error {
 export class TypertGatewayService extends Service implements TypertGateway {
 export class TypertGatewayService extends Service implements TypertGateway {
   static inject = ['typert']
   static inject = ['typert']
 
 
+  /** Carrier adapter shared by the WebSocket mux and local Host transports. */
+  readonly wireStream: TypertGatewayWireStream = {
+    open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
+    failure: error => rpcError(error),
+  }
+
   private srcClaims: ReadonlySet<string> | undefined
   private srcClaims: ReadonlySet<string> | undefined
+  private remoteEvents: RegisteredRemoteEventSource | undefined
+  private readonly remoteEventClients = new Map<RemoteEventClientId, RemoteEventClient>()
+  private readonly pendingRemoteEvents = new Map<RemoteEventId, PendingRemoteEvent>()
 
 
   /**
   /**
    * Register the Gateway against the active Typert registry.
    * Register the Gateway against the active Typert registry.
@@ -109,9 +185,63 @@ export class TypertGatewayService extends Service implements TypertGateway {
         { authority: 'trusted-host' },
         { authority: 'trusted-host' },
       )
       )
     })
     })
+    ctx.inject(['connection', 'webServer'], (webCtx) => {
+      const mux = new RemoteStreamMuxServer(
+        (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
+        this.wireStream.failure,
+      )
+      webCtx.effect(() => {
+        const route: WebUpgradeRoute = {
+          path: REMOTE_STREAM_MUX_PATH,
+          handler: (req, socket, head) => {
+            if (!webCtx.connection.isTrustedRequest(req, 'trusted-host')) {
+              rejectRemoteStreamUpgrade(socket)
+              return
+            }
+            mux.handleUpgrade(req, socket, head)
+          },
+        }
+        const unregister = webCtx.webServer.registerUpgrade(route)
+        return async () => {
+          unregister()
+          await mux.close()
+        }
+      }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`)
+    })
+  }
+
+  /**
+   * Register the sole application-selected forwarded-event source.
+   * @param source - stream factory installed by the Remote assembly.
+   * @returns disposer removing this source and cancelling its active streams.
+   */
+  registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void> {
+    if (this.remoteEvents !== undefined) {
+      throw new Error('typert gateway: forwarded Remote event source is already registered')
+    }
+    const lifetime = new AbortController()
+    const stream = source(lifetime.signal)
+    const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => {
+      if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return
+      this.closeRemoteEvents(error)
+      this.remoteEvents = undefined
+      lifetime.abort(error)
+    })
+    const registration: RegisteredRemoteEventSource = { lifetime, done }
+    this.remoteEvents = registration
+    return async () => {
+      if (this.remoteEvents === registration) {
+        this.remoteEvents = undefined
+        const error = new Error('typert gateway: forwarded Remote event source was removed')
+        registration.lifetime.abort(error)
+        this.closeRemoteEvents(error)
+      }
+      await registration.done
+    }
   }
   }
 
 
   private claimsEndpoint(endpoint: string): boolean {
   private claimsEndpoint(endpoint: string): boolean {
+    if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true
     const segments = endpoint.split('/')
     const segments = endpoint.split('/')
     if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
     if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
     if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
     if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
@@ -143,44 +273,64 @@ export class TypertGatewayService extends Service implements TypertGateway {
    * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
    * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
    */
    */
   async invoke(request: InvokeRemoteRequest): Promise<unknown> {
   async invoke(request: InvokeRemoteRequest): Promise<unknown> {
-    const endpoint = endpointOf(request.namespace, request.method)
-    const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
-    assertExactArguments(request.args, descriptor, endpoint)
-    const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
-    const receiver = receiverContext.get(descriptor.service) as unknown
-    if (!isObject(receiver)) {
-      throw new TypertGatewayError(
-        'service-unavailable',
-        endpoint,
-        `active Service ${JSON.stringify(descriptor.service)} is unavailable`,
-      )
-    }
-    validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
-    const args = await Promise.all(descriptor.parameters.map(parameter =>
-      this.resolveParameter(parameter, request.args, endpoint)))
-    if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
-    const implementation = descriptor.implementation ?? descriptor.method
-    const method = Reflect.get(receiver, implementation) as unknown
-    if (typeof method !== 'function') {
+    const prepared = await this.prepareInvocation(request)
+    if (prepared.descriptor.mode === 'stream') {
       throw new TypertGatewayError(
       throw new TypertGatewayError(
-        'method-unavailable',
-        endpoint,
-        `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
+        'signature-invalid',
+        prepared.endpoint,
+        'stream Remote methods must be opened through the stream carrier',
       )
       )
     }
     }
 
 
     let result: unknown
     let result: unknown
     try {
     try {
-      result = await Reflect.apply(method, receiver, args) as unknown
+      result = await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
     } catch (error) {
     } catch (error) {
-      if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)
+      if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error)
       throw error
       throw error
     }
     }
     // A weak descriptor declares no return type, so nothing returned is a void
     // A weak descriptor declares no return type, so nothing returned is a void
     // result and rides the wire as an absent value field. A strict descriptor
     // result and rides the wire as an absent value field. A strict descriptor
     // keeps its schema: there, undefined has to be a declared result.
     // keeps its schema: there, undefined has to be a declared result.
-    if (result === undefined && descriptor.result.mode !== 'strict') return result
-    return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
+    if (result === undefined && prepared.descriptor.result.mode !== 'strict') return result
+    return decode(prepared.descriptor.result, result, 'result-invalid', prepared.endpoint, 'result')
+  }
+
+  /**
+   * Open one live stream Remote method without assuming a physical carrier.
+   * @param request - decoded endpoint and named wire arguments.
+   * @returns an iterable whose items have passed the generated result codec.
+   */
+  async stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>> {
+    const prepared = await this.prepareInvocation(request)
+    if (prepared.descriptor.mode !== 'stream') {
+      throw new TypertGatewayError(
+        'signature-invalid',
+        prepared.endpoint,
+        'unary Remote methods cannot be opened through the stream carrier',
+      )
+    }
+    let source: unknown
+    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)
+      throw error
+    }
+    if (!isIterable(source)) {
+      throw new TypertGatewayError(
+        'result-invalid',
+        prepared.endpoint,
+        'stream Remote method did not return Iterable or AsyncIterable',
+        { field: 'result' },
+      )
+    }
+    return validatedStream(
+      source,
+      prepared.descriptor.result,
+      prepared.endpoint,
+      request.signal ?? NEVER_ABORTED_SIGNAL,
+    )
   }
   }
 
 
   private async dispatchRpc(
   private async dispatchRpc(
@@ -188,30 +338,240 @@ export class TypertGatewayService extends Service implements TypertGateway {
     payload: unknown,
     payload: unknown,
     signal: AbortSignal,
     signal: AbortSignal,
   ): Promise<ConnectionRpcResult> {
   ): Promise<ConnectionRpcResult> {
+    if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
+      try {
+        const result = parseRemoteEventResultPayload(payload)
+        const client = this.remoteEventClients.get(result.clientId)
+        if (client === undefined) {
+          throw new Error('typert gateway: Remote event result identifies no active event stream')
+        }
+        this.receiveRemoteEventResult(client, result)
+        return { ok: true, value: undefined }
+      } catch (error) {
+        return rpcFailure(error)
+      }
+    }
     return this.invokeRpc(endpoint, payload, signal)
     return this.invokeRpc(endpoint, payload, signal)
   }
   }
 
 
-  private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
+  private async openWireStream(
+    endpoint: string,
+    payload: unknown,
+    signal: AbortSignal,
+  ): Promise<AsyncIterable<unknown>> {
+    if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) {
+      return this.openRemoteEvents(payload, signal)
+    }
+    return this.stream(remoteRequest(endpoint, payload, signal))
+  }
+
+  private async *openRemoteEvents(
+    payload: unknown,
+    signal: AbortSignal,
+  ): AsyncGenerator<
+    RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame
+    | RemoteEventReadyFrame
+  > {
+    if (!isObject(payload)
+      || !isPlainObject(payload)
+      || Reflect.ownKeys(payload).length !== 1
+      || !Object.hasOwn(payload, 'args')
+      || !isObject(payload.args)
+      || !isPlainObject(payload.args)
+      || Reflect.ownKeys(payload.args).length !== 0) {
+      throw new TypertGatewayError(
+        'arguments-invalid',
+        REMOTE_EVENT_STREAM_ENDPOINT,
+        'forwarded Remote event stream requires an empty args object',
+      )
+    }
+    const registration = this.remoteEvents
+    if (registration === undefined) {
+      throw new TypertGatewayError(
+        'service-unavailable',
+        REMOTE_EVENT_STREAM_ENDPOINT,
+        'forwarded Remote event source is unavailable',
+      )
+    }
+    const lifetime = AbortSignal.any([signal, registration.lifetime.signal])
+    let clientId = randomUUID() as RemoteEventClientId
+    while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId
+    const client: RemoteEventClient = {
+      id: clientId,
+      queue: new RemoteEventQueue(),
+      deliveries: new Map(),
+    }
+    this.remoteEventClients.set(clientId, client)
+    for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client)
     try {
     try {
-      const segments = endpoint.split('/')
-      if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
-        throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
+      yield { ...REMOTE_EVENT_STREAM_READY, clientId }
+      yield* client.queue.iterate(lifetime)
+    } finally {
+      this.removeRemoteEventClient(client)
+    }
+  }
+
+  private async consumeRemoteEvents(
+    source: AsyncIterable<TypertRemoteEventDispatch>,
+    signal: AbortSignal,
+  ): Promise<void> {
+    for await (const dispatch of source) {
+      if (signal.aborted) {
+        if ('context' in dispatch) dispatch.reject(signal.reason)
+        return
       }
       }
-      const [namespace, method] = segments as [string, string]
-      if (!isObject(payload)
-        || !isPlainObject(payload)
-        || Reflect.ownKeys(payload).length !== 1
-        || !Object.hasOwn(payload, 'args')
-        || !isObject(payload.args)
-        || !isPlainObject(payload.args)) {
-        throw new Error('Remote payload must contain exactly one plain-object args field')
+      if ('context' in dispatch) this.startRemoteEvent(dispatch)
+      else this.broadcastRemoteEvent(dispatch)
+    }
+    if (!signal.aborted) {
+      throw new Error('typert gateway: forwarded Remote event source ended unexpectedly')
+    }
+  }
+
+  private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void {
+    assertRemoteEventFrame(frame)
+    const wire: RemoteEventEmitFrame = {
+      type: 'emit',
+      event: frame.event,
+      args: frame.args,
+    }
+    for (const client of this.remoteEventClients.values()) client.queue.push(wire)
+  }
+
+  private startRemoteEvent(source: TypertRemoteEventInvocation): void {
+    try {
+      assertRemoteEventName(source)
+      const context = this.ctx.typert.contexts.identifyHost(source.context.value)
+      if (context === undefined) {
+        source.resolve({ kind: 'next' })
+        return
       }
       }
-      const value = await this.invoke({
-        namespace,
-        method,
-        args: payload.args,
-        signal,
+      if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) {
+        throw new TypeError(
+          'typert gateway: scoped Remote events require a non-empty Agent identity',
+        )
+      }
+      const projected = projectRemoteEventRequest(source.request, source.context.subject)
+      let id = randomUUID() as RemoteEventId
+      while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId
+      let releaseContext: () => void
+      try {
+        const dispose = source.context.value.effect(
+          () => () => {
+            this.cancelRemoteEvent(
+              pending,
+              new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`),
+            )
+          },
+          `api-gateway: Remote event ${JSON.stringify(source.event)}`,
+        )
+        releaseContext = () => { void dispose() }
+      } catch {
+        source.resolve({ kind: 'next' })
+        return
+      }
+      const signals = new Set(projected.signal === undefined ? [] : [projected.signal])
+      const abort = (): void => {
+        const reason = [...signals].find(signal => signal.aborted)?.reason as unknown
+        this.cancelRemoteEvent(pending, reason instanceof Error
+          ? reason
+          : new Error('typert gateway: Remote event was cancelled', { cause: reason }))
+      }
+      const pending: PendingRemoteEvent = {
+        id,
+        source,
+        frame: {
+          type: 'waterfall',
+          event: source.event,
+          eventId: id,
+          agentId: context.identity,
+          request: projected.request,
+        },
+        deliveries: new Set(),
+        releaseContext,
+        releaseSignal: () => {
+          for (const signal of signals) signal.removeEventListener('abort', abort)
+        },
+      }
+      this.pendingRemoteEvents.set(id, pending)
+      for (const signal of signals) signal.addEventListener('abort', abort, { once: true })
+      if ([...signals].some(signal => signal.aborted)) abort()
+      else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client)
+    } catch (error) {
+      source.reject(error)
+    }
+  }
+
+  private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void {
+    pending.deliveries.add(client)
+    client.deliveries.set(pending.id, pending)
+    client.queue.push(pending.frame)
+  }
+
+  private receiveRemoteEventResult(
+    client: RemoteEventClient,
+    result: ReturnType<typeof parseRemoteEventResult>,
+  ): void {
+    const pending = this.pendingRemoteEvents.get(result.eventId)
+    if (pending === undefined || !pending.deliveries.has(client)) return
+    this.removeRemoteEventDelivery(pending, client)
+    if (result.outcome.kind === 'result') {
+      this.settleRemoteEvent(pending, {
+        kind: 'result',
+        value: result.outcome.value,
       })
       })
+    } else if (result.outcome.kind === 'rejected') {
+      this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error))
+    } else if (pending.deliveries.size === 0) {
+      this.settleRemoteEvent(pending, { kind: 'next' })
+    }
+  }
+
+  private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void {
+    pending.deliveries.delete(client)
+    client.deliveries.delete(pending.id)
+  }
+
+  private removeRemoteEventClient(client: RemoteEventClient): void {
+    this.remoteEventClients.delete(client.id)
+    for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client)
+    client.queue.end()
+  }
+
+  private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void {
+    this.finishRemoteEvent(pending)
+    pending.source.resolve(outcome)
+  }
+
+  private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void {
+    if (this.pendingRemoteEvents.get(pending.id) !== pending) return
+    this.finishRemoteEvent(pending)
+    pending.source.reject(reason)
+  }
+
+  private finishRemoteEvent(pending: PendingRemoteEvent): void {
+    this.pendingRemoteEvents.delete(pending.id)
+    pending.releaseSignal()
+    pending.releaseContext()
+    const clients = new Set(pending.deliveries)
+    for (const client of clients) this.removeRemoteEventDelivery(pending, client)
+    const cancellation: RemoteEventCancellationFrame = {
+      type: 'cancel',
+      eventId: pending.id,
+    }
+    for (const client of clients) client.queue.push(cancellation)
+  }
+
+  private closeRemoteEvents(reason: unknown): void {
+    for (const pending of [...this.pendingRemoteEvents.values()]) {
+      this.cancelRemoteEvent(pending, reason)
+    }
+    for (const client of [...this.remoteEventClients.values()]) client.queue.end()
+  }
+
+  private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
+    try {
+      const value = await this.invoke(remoteRequest(endpoint, payload, signal))
       // A void or explicitly absent business result carries no `value` field;
       // A void or explicitly absent business result carries no `value` field;
       // JSON has no `undefined`, and the envelope's optional slot is the one
       // JSON has no `undefined`, and the envelope's optional slot is the one
       // representation of absence that both args and results already use.
       // representation of absence that both args and results already use.
@@ -221,6 +581,35 @@ export class TypertGatewayService extends Service implements TypertGateway {
     }
     }
   }
   }
 
 
+  private async prepareInvocation(request: InvokeRemoteRequest): Promise<PreparedInvocation> {
+    const endpoint = endpointOf(request.namespace, request.method)
+    const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
+    assertExactArguments(request.args, descriptor, endpoint)
+    const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
+    const receiver = receiverContext.get(descriptor.service) as unknown
+    if (!isObject(receiver)) {
+      throw new TypertGatewayError(
+        'service-unavailable',
+        endpoint,
+        `active Service ${JSON.stringify(descriptor.service)} is unavailable`,
+      )
+    }
+    validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
+    const args = await Promise.all(descriptor.parameters.map(parameter =>
+      this.resolveParameter(parameter, request.args, endpoint)))
+    if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
+    const implementation = descriptor.implementation ?? descriptor.method
+    const method = Reflect.get(receiver, implementation) as unknown
+    if (typeof method !== 'function') {
+      throw new TypertGatewayError(
+        'method-unavailable',
+        endpoint,
+        `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
+      )
+    }
+    return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown }
+  }
+
   private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
   private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
     const strict = this.ctx.typert.local.get(endpoint)
     const strict = this.ctx.typert.local.get(endpoint)
     if (strict !== undefined) return strict
     if (strict !== undefined) return strict
@@ -349,6 +738,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
       namespace: binding.namespace,
       namespace: binding.namespace,
       method,
       method,
       ...(marker.method === method ? {} : { implementation: marker.method }),
       ...(marker.method === method ? {} : { implementation: marker.method }),
+      ...(marker.mode === undefined ? {} : { mode: marker.mode }),
       invocation: receiver,
       invocation: receiver,
       parameters,
       parameters,
       ...(cancellation === undefined ? {} : { cancellation }),
       ...(cancellation === undefined ? {} : { cancellation }),
@@ -468,6 +858,121 @@ export class TypertGatewayService extends Service implements TypertGateway {
   }
   }
 }
 }
 
 
+type RemoteEventWireFrame =
+  | RemoteEventEmitFrame
+  | RemoteEventInvocationFrame
+  | RemoteEventCancellationFrame
+
+/** Pull-driven queue owned by one connected Client event generation. */
+class RemoteEventQueue {
+  private readonly frames: RemoteEventWireFrame[] = []
+  private waiter: (() => void) | undefined
+  private closed = false
+
+  push(frame: RemoteEventWireFrame): void {
+    if (this.closed) return
+    this.frames.push(frame)
+    this.waiter?.()
+  }
+
+  end(): void {
+    if (this.closed) return
+    this.closed = true
+    this.waiter?.()
+  }
+
+  async *iterate(signal: AbortSignal): AsyncGenerator<RemoteEventWireFrame> {
+    const abort = (): void => { this.end() }
+    signal.addEventListener('abort', abort, { once: true })
+    try {
+      while (true) {
+        while (this.frames.length > 0) yield this.frames.shift() as RemoteEventWireFrame
+        if (this.closed || signal.aborted) return
+        await new Promise<void>((resolve) => { this.waiter = resolve })
+        this.waiter = undefined
+      }
+    } finally {
+      signal.removeEventListener('abort', abort)
+    }
+  }
+}
+
+function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void {
+  assertRemoteEventName(frame)
+  if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) {
+    throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`)
+  }
+}
+
+function assertRemoteEventName(frame: { readonly event: unknown }): void {
+  if (typeof frame.event !== 'string' || frame.event.length === 0) {
+    throw new TypeError('typert gateway: Remote event name must be a nonempty string')
+  }
+}
+
+function parseRemoteEventResultPayload(payload: unknown): ReturnType<typeof parseRemoteEventResult> {
+  if (!isObject(payload)
+    || !isPlainObject(payload)
+    || Reflect.ownKeys(payload).length !== 1
+    || !Object.hasOwn(payload, 'args')) {
+    throw new Error('typert gateway: Remote event result requires exactly one plain-object args field')
+  }
+  return parseRemoteEventResult(payload.args)
+}
+
+function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest {
+  const segments = endpoint.split('/')
+  if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
+    throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
+  }
+  const [namespace, method] = segments as [string, string]
+  if (!isObject(payload)
+    || !isPlainObject(payload)
+    || Reflect.ownKeys(payload).length !== 1
+    || !Object.hasOwn(payload, 'args')
+    || !isObject(payload.args)
+    || !isPlainObject(payload.args)) {
+    throw new Error('Remote payload must contain exactly one plain-object args field')
+  }
+  return { namespace, method, args: payload.args, signal }
+}
+
+function isIterable(value: unknown): value is Iterable<unknown> | AsyncIterable<unknown> {
+  return isObject(value)
+    && (typeof Reflect.get(value, Symbol.iterator) === 'function'
+      || typeof Reflect.get(value, Symbol.asyncIterator) === 'function')
+}
+
+async function *validatedStream(
+  source: Iterable<unknown> | AsyncIterable<unknown>,
+  codec: TypertCodec,
+  endpoint: string,
+  signal: AbortSignal,
+): AsyncGenerator {
+  const asyncFactory = Reflect.get(source, Symbol.asyncIterator) as unknown
+  const syncFactory = Reflect.get(source, Symbol.iterator) as unknown
+  const iterator = typeof asyncFactory === 'function'
+    ? Reflect.apply(asyncFactory, source, []) as AsyncIterator<unknown>
+    : Reflect.apply(syncFactory as (...args: never[]) => Iterator<unknown>, source, [])
+  let rejectAbort: ((error: unknown) => void) | undefined
+  const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
+  const onAbort = (): void => {
+    rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason))
+  }
+  signal.addEventListener('abort', onAbort, { once: true })
+  try {
+    if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason)
+    while (true) {
+      const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
+      if (next.done === true) return
+      yield decode(codec, next.value, 'result-invalid', endpoint, 'result')
+    }
+  } finally {
+    signal.removeEventListener('abort', onAbort)
+    await iterator.return?.()
+  }
+}
+
 function rpcFailure(error: unknown): ConnectionRpcResult {
 function rpcFailure(error: unknown): ConnectionRpcResult {
   if (error instanceof RemoteInvocationCancelled) {
   if (error instanceof RemoteInvocationCancelled) {
     return {
     return {
@@ -478,6 +983,9 @@ function rpcFailure(error: unknown): ConnectionRpcResult {
   if (error instanceof TypertLookupFailure) {
   if (error instanceof TypertLookupFailure) {
     return { ok: false, error: error.failure as ConnectionRpcError }
     return { ok: false, error: error.failure as ConnectionRpcError }
   }
   }
+  if (error instanceof TypertRemoteFailure) {
+    return { ok: false, error: error.failure }
+  }
   return {
   return {
     ok: false,
     ok: false,
     error: {
     error: {
@@ -488,6 +996,10 @@ function rpcFailure(error: unknown): ConnectionRpcResult {
   }
   }
 }
 }
 
 
+function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure {
+  return (rpcFailure(error) as Extract<ConnectionRpcResult, { readonly ok: false }>).error
+}
+
 function endpointOf(namespace: string, method: string): string {
 function endpointOf(namespace: string, method: string): string {
   return `${namespace}/${method}`
   return `${namespace}/${method}`
 }
 }

+ 399 - 0
packages/api/gateway/src/stream-protocol.ts

@@ -0,0 +1,399 @@
+/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */
+
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** Exact WebSocket route carrying every Typert Remote stream. */
+export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux'
+
+/** Gateway-internal logical stream carrying application-selected Cordis events. */
+export const REMOTE_EVENT_STREAM_ENDPOINT = '$events'
+
+/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */
+export const REMOTE_EVENT_RESULT_ENDPOINT = '$events/result'
+
+/** Empty standard Remote payload used to open the forwarded-event stream. */
+export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const
+
+/** Discriminator for the first item proving the Host event source is ready. */
+export const REMOTE_EVENT_STREAM_READY = { type: 'ready' } as const
+
+/** Opaque identity for one active Client Remote Event generation. */
+export type RemoteEventClientId = Branded<'RemoteEventClientId'>
+
+/** Opaque correlation id for one pending Host-to-Client Remote Event. */
+export type RemoteEventId = Branded<'RemoteEventId'>
+
+/** Opening item that binds later HTTP results to this active event stream. */
+export interface RemoteEventReadyFrame {
+  readonly type: 'ready'
+  readonly clientId: RemoteEventClientId
+}
+
+/** Opaque Agent identity carried by one scoped Remote Event. */
+export type RemoteEventAgentId = Branded<'RemoteEventAgentId'>
+
+/** One Host notification delivered to a Client generation. */
+export interface RemoteEventEmitFrame {
+  readonly type: 'emit'
+  readonly event: string
+  readonly args: readonly unknown[]
+}
+
+/** One pending Agent-scoped waterfall delivered to a Client generation. */
+export interface RemoteEventInvocationFrame {
+  readonly type: 'waterfall'
+  readonly event: string
+  readonly eventId: RemoteEventId
+  readonly agentId: RemoteEventAgentId
+  readonly request: Readonly<Record<string, unknown>>
+}
+
+/** Cancellation of a pending waterfall previously delivered under the same id. */
+export interface RemoteEventCancellationFrame {
+  readonly type: 'cancel'
+  readonly eventId: RemoteEventId
+}
+
+/** Every item carried by the Gateway-internal forwarded-event stream. */
+export type RemoteEventDownlinkFrame =
+  | RemoteEventReadyFrame
+  | RemoteEventEmitFrame
+  | RemoteEventInvocationFrame
+  | RemoteEventCancellationFrame
+
+/** JSON request fields plus the Host cancellation lifetime removed for transport. */
+export interface ProjectedRemoteEventRequest {
+  readonly request: Readonly<Record<string, unknown>>
+  readonly signal?: AbortSignal
+}
+
+/** Error fields retained when a Client listener rejects a Host waterfall. */
+export interface RemoteEventRejection {
+  readonly name: string
+  readonly message: string
+  readonly code?: string
+  readonly details?: unknown
+}
+
+/** Client response to one scoped Remote Event delivery. */
+export interface RemoteEventResult {
+  readonly clientId: RemoteEventClientId
+  readonly eventId: RemoteEventId
+  readonly outcome:
+    | { readonly kind: 'next' }
+    | { readonly kind: 'result'; readonly value?: unknown }
+    | { readonly kind: 'rejected'; readonly error: RemoteEventRejection }
+}
+
+/**
+ * Parse one result sent through the Client's `$events/result` HTTP RPC.
+ * @param value - untrusted result payload.
+ * @returns validated event correlation and outcome fields.
+ */
+export function parseRemoteEventResult(value: unknown): RemoteEventResult {
+  if (!isRecord(value)
+    || !exactKeys(value, ['clientId', 'eventId', 'outcome'])
+    || !isRemoteEventClientId(value.clientId)
+    || !isRemoteEventId(value.eventId)
+    || !isRecord(value.outcome)) {
+    throw new Error('api gateway: invalid Remote event result')
+  }
+  const outcome = value.outcome
+  if (outcome.kind === 'next' && exactKeys(outcome, ['kind'])) {
+    return {
+      clientId: value.clientId,
+      eventId: value.eventId,
+      outcome: { kind: 'next' },
+    }
+  }
+  if (outcome.kind === 'result'
+    && (exactKeys(outcome, ['kind']) || exactKeys(outcome, ['kind', 'value']))
+    && (!Object.hasOwn(outcome, 'value') || isRemoteJsonValue(outcome.value))) {
+    return {
+      clientId: value.clientId,
+      eventId: value.eventId,
+      outcome: Object.hasOwn(outcome, 'value')
+        ? { kind: 'result', value: outcome.value }
+        : { kind: 'result' },
+    }
+  }
+  if (outcome.kind === 'rejected'
+    && exactKeys(outcome, ['kind', 'error'])) {
+    return {
+      clientId: value.clientId,
+      eventId: value.eventId,
+      outcome: { kind: 'rejected', error: parseRemoteEventRejection(outcome.error) },
+    }
+  }
+  throw new Error('api gateway: invalid Remote event result')
+}
+
+/**
+ * Remove the direct Agent and cancellation fields from one waterfall request.
+ * @param value - request object before the waterfall's `next` callback.
+ * @param subject - Agent used by the Cordis scope carrier.
+ * @returns JSON-safe request fields and the optional Host cancellation signal.
+ */
+export function projectRemoteEventRequest(
+  value: unknown,
+  subject: object,
+): ProjectedRemoteEventRequest {
+  if (!isPlainRecord(value) || !Object.hasOwn(value, 'agent') || value.agent !== subject) {
+    throw new TypeError('api gateway: Remote event request must carry its scoped Agent directly')
+  }
+  const signal = value.signal
+  if (signal !== undefined && !(signal instanceof AbortSignal)) {
+    throw new TypeError('api gateway: Remote event request signal must be an AbortSignal')
+  }
+  const request: Record<string, unknown> = Object.create(null) as Record<string, unknown>
+  for (const key of Reflect.ownKeys(value)) {
+    if (key === 'agent' || key === 'signal') continue
+    const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined
+    if (typeof key !== 'string' || descriptor?.enumerable !== true) {
+      throw new TypeError('api gateway: Remote event request has a non-JSON property')
+    }
+    request[key] = Reflect.get(value, key)
+  }
+  if (!isRemoteJsonValue(request)) {
+    throw new TypeError('api gateway: Remote event request is not lossless JSON data')
+  }
+  return {
+    request,
+    ...(signal === undefined ? {} : { signal }),
+  }
+}
+
+/**
+ * Project an arbitrary rejection to stable, JSON-safe error fields.
+ * @param reason - value thrown or rejected by a Client listener.
+ * @returns wire-safe rejection fields.
+ */
+export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection {
+  const record = typeof reason === 'object' && reason !== null ? reason : undefined
+  const name = stringProperty(record, 'name') ?? 'Error'
+  const message = stringProperty(record, 'message') ?? String(reason)
+  const code = stringProperty(record, 'code')
+  const details = record === undefined ? undefined : Reflect.get(record, 'details') as unknown
+  return {
+    name,
+    message,
+    ...(code === undefined ? {} : { code }),
+    ...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }),
+  }
+}
+
+/**
+ * Recreate a Client rejection for the Host continuation.
+ * @param rejection - validated wire-safe error fields.
+ * @returns an Error preserving the remote name, code, and JSON-safe details.
+ */
+export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error {
+  const error = new Error(rejection.message) as Error & { code?: string; details?: unknown }
+  error.name = rejection.name
+  if (rejection.code !== undefined) error.code = rejection.code
+  if (rejection.details !== undefined) error.details = rejection.details
+  return error
+}
+
+/**
+ * Test whether a value crosses JSON transport without coercion or omission.
+ * @param value - candidate boundary value.
+ * @returns whether the value is losslessly JSON-compatible.
+ */
+export function isRemoteJsonValue(value: unknown): boolean {
+  return visitJsonValue(value, new Set<object>())
+}
+
+/**
+ * Recognize a non-empty Remote Event correlation id at a wire boundary.
+ * @param value - untrusted wire value.
+ * @returns whether the value is a valid Remote Event id.
+ */
+export function isRemoteEventId(value: unknown): value is RemoteEventId {
+  return typeof value === 'string' && value.length > 0
+}
+
+/**
+ * Recognize a non-empty Remote Event Client id at a wire boundary.
+ * @param value - untrusted wire value.
+ * @returns whether the value identifies one event-stream generation.
+ */
+export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId {
+  return typeof value === 'string' && value.length > 0
+}
+
+/**
+ * Recognize the direct Agent identity used by a scoped Remote Event.
+ * @param value - untrusted wire value.
+ * @returns whether the value is a non-empty Agent identity.
+ */
+export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId {
+  return typeof value === 'string' && value.length > 0
+}
+
+/** One logical stream request sent from the browser. */
+export type RemoteStreamClientMessage =
+  | {
+    readonly type: 'open'
+    readonly streamId: string
+    readonly endpoint: string
+    readonly payload: unknown
+  }
+  | { readonly type: 'cancel'; readonly streamId: string }
+
+/** Carrier-safe failure delivered by the Host. */
+export interface RemoteStreamFailure {
+  readonly code: string
+  readonly message: string
+  readonly details: object
+}
+
+/** One logical stream frame sent from the Host. */
+export type RemoteStreamServerMessage =
+  | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown }
+  | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure }
+  | { readonly type: 'end'; readonly streamId: string }
+
+/**
+ * Parse and validate one browser-to-Host text message.
+ * @param text - complete WebSocket text message.
+ * @returns the validated logical-stream request.
+ */
+export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage {
+  return parseMessage(text, (value) => {
+    if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) {
+      return value as unknown as RemoteStreamClientMessage
+    }
+    if (value.type === 'open'
+      && exactKeys(value, ['type', 'streamId', 'endpoint', 'payload'])
+      && validId(value.streamId)
+      && typeof value.endpoint === 'string'
+      && value.endpoint.length > 0) {
+      return value as unknown as RemoteStreamClientMessage
+    }
+    throw new Error('api gateway: invalid Remote stream client message')
+  })
+}
+
+/**
+ * Parse and validate one Host-to-browser text message.
+ * @param text - complete WebSocket text message.
+ * @returns the validated logical-stream frame.
+ */
+export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage {
+  return parseMessage(text, (value) => {
+    if (value.type === 'item'
+      && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value']))
+      && validId(value.streamId)) {
+      return value as unknown as RemoteStreamServerMessage
+    }
+    if (value.type === 'end' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) {
+      return value as unknown as RemoteStreamServerMessage
+    }
+    if (value.type === 'error'
+      && exactKeys(value, ['type', 'streamId', 'error'])
+      && validId(value.streamId)
+      && isRecord(value.error)
+      && exactKeys(value.error, ['code', 'message', 'details'])
+      && typeof value.error.code === 'string'
+      && typeof value.error.message === 'string'
+      && isRecord(value.error.details)) {
+      return value as unknown as RemoteStreamServerMessage
+    }
+    throw new Error('api gateway: invalid Remote stream server message')
+  })
+}
+
+function parseMessage<T>(text: string, validate: (value: Record<string, unknown>) => T): T {
+  let decoded: unknown
+  try {
+    decoded = JSON.parse(text) as unknown
+  } catch (cause) {
+    throw new Error('api gateway: Remote stream message is not JSON', { cause })
+  }
+  if (!isRecord(decoded)) throw new Error('api gateway: Remote stream message must be an object')
+  return validate(decoded)
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object'
+    && value !== null
+    && !Array.isArray(value)
+}
+
+function isPlainRecord(value: unknown): value is Record<string, unknown> {
+  if (!isRecord(value)) return false
+  const prototype: unknown = Object.getPrototypeOf(value)
+  return prototype === Object.prototype || prototype === null
+}
+
+function exactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
+  const keys = Reflect.ownKeys(value)
+  return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key))
+}
+
+function validId(value: unknown): value is string {
+  return typeof value === 'string' && value.length > 0
+}
+
+function parseRemoteEventRejection(value: unknown): RemoteEventRejection {
+  if (!isRecord(value)
+    || !hasOnlyKeys(value, ['name', 'message'], ['code', 'details'])
+    || typeof value.name !== 'string'
+    || value.name.length === 0
+    || typeof value.message !== 'string'
+    || (Object.hasOwn(value, 'code') && typeof value.code !== 'string')
+    || (Object.hasOwn(value, 'details') && !isRemoteJsonValue(value.details))) {
+    throw new Error('api gateway: invalid Remote event rejection')
+  }
+  return {
+    name: value.name,
+    message: value.message,
+    ...(typeof value.code === 'string' ? { code: value.code } : {}),
+    ...(Object.hasOwn(value, 'details') ? { details: value.details } : {}),
+  }
+}
+
+function hasOnlyKeys(
+  value: Record<string, unknown>,
+  required: readonly string[],
+  optional: readonly string[],
+): boolean {
+  const keys = Reflect.ownKeys(value)
+  return required.every(key => Object.hasOwn(value, key))
+    && keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key)))
+}
+
+function stringProperty(value: object | undefined, key: string): string | undefined {
+  if (value === undefined) return undefined
+  const candidate: unknown = Reflect.get(value, key)
+  return typeof candidate === 'string' ? candidate : undefined
+}
+
+function visitJsonValue(value: unknown, ancestors: Set<object>): boolean {
+  if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
+  if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0)
+  if (typeof value !== 'object') return false
+  if (ancestors.has(value)) return false
+  ancestors.add(value)
+  try {
+    if (Array.isArray(value)) {
+      if (Object.getPrototypeOf(value) !== Array.prototype
+        || Reflect.ownKeys(value).length !== value.length + 1) return false
+      for (let index = 0; index < value.length; index++) {
+        if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false
+      }
+      return true
+    }
+    const prototype: unknown = Object.getPrototypeOf(value)
+    if (prototype !== Object.prototype && prototype !== null) return false
+    for (const key of Reflect.ownKeys(value)) {
+      if (typeof key !== 'string') return false
+      const descriptor = Object.getOwnPropertyDescriptor(value, key)
+      if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false
+    }
+    return true
+  } finally {
+    ancestors.delete(value)
+  }
+}

+ 188 - 0
packages/api/gateway/src/stream-server.ts

@@ -0,0 +1,188 @@
+/** Host WebSocket owner for multiplexed Typert Remote streams. */
+
+import type { IncomingMessage } from 'node:http'
+import type { Duplex } from 'node:stream'
+import WebSocket, { WebSocketServer, type RawData } from 'ws'
+import {
+  parseRemoteStreamClientMessage,
+  type RemoteStreamFailure,
+  type RemoteStreamServerMessage,
+} from './stream-protocol.ts'
+
+/** Open one validated Remote stream for a decoded wire request. */
+export type RemoteStreamOpener = (
+  endpoint: string,
+  payload: unknown,
+  signal: AbortSignal,
+) => Promise<AsyncIterable<unknown>>
+
+/** Convert an invocation or carrier failure to a stable wire value. */
+export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
+
+/** Own the no-server WebSocket acceptor and every active logical stream. */
+export class RemoteStreamMuxServer {
+  private readonly server = new WebSocketServer({ noServer: true })
+  private readonly connections = new Set<Promise<void>>()
+
+  /**
+   * @param open - Gateway stream dispatcher.
+   * @param failure - Gateway error-to-wire mapper.
+   */
+  constructor(
+    private readonly open: RemoteStreamOpener,
+    private readonly failure: RemoteStreamFailureMapper,
+  ) {}
+
+  /**
+   * Upgrade one trusted request and begin serving its logical streams.
+   * @param req - authenticated HTTP upgrade request.
+   * @param socket - carrier socket transferred to the WebSocket server.
+   * @param head - bytes already read after the HTTP upgrade headers.
+   */
+  handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
+    this.server.handleUpgrade(req, socket, head, (websocket) => {
+      const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
+      const done = connection.run()
+      this.connections.add(done)
+      void done.then(() => { this.connections.delete(done) })
+    })
+  }
+
+  /** Terminate all sockets and wait until every iterator has returned. */
+  async close(): Promise<void> {
+    for (const socket of this.server.clients) socket.terminate()
+    const closed = Promise.withResolvers<void>()
+    this.server.close((error) => {
+      if (error === undefined) closed.resolve()
+      else closed.reject(error)
+    })
+    await closed.promise
+    await Promise.all(this.connections)
+  }
+}
+
+interface ActiveStream {
+  readonly abort: AbortController
+  done: Promise<void>
+}
+
+class RemoteStreamMuxConnection {
+  private readonly streams = new Map<string, ActiveStream>()
+  private writes = Promise.resolve()
+
+  constructor(
+    private readonly socket: WebSocket,
+    private readonly open: RemoteStreamOpener,
+    private readonly failure: RemoteStreamFailureMapper,
+  ) {}
+
+  async run(): Promise<void> {
+    const closed = new Promise<void>((resolve) => {
+      this.socket.once('close', resolve)
+      this.socket.once('error', () => { this.socket.terminate() })
+      this.socket.on('message', (data, isBinary) => {
+        if (isBinary) {
+          this.socket.close(1003, 'text messages required')
+          return
+        }
+        try {
+          this.receive(rawText(data))
+        } catch {
+          this.socket.close(1008, 'invalid Remote stream request')
+        }
+      })
+    })
+    await closed
+    const active = [...this.streams.values()]
+    for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed'))
+    await Promise.all(active.map(stream => stream.done))
+  }
+
+  private receive(text: string): void {
+    const message = parseRemoteStreamClientMessage(text)
+    if (message.type === 'cancel') {
+      this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled'))
+      return
+    }
+    if (this.streams.has(message.streamId)) {
+      throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`)
+    }
+    const abort = new AbortController()
+    const active: ActiveStream = {
+      abort,
+      done: Promise.resolve(),
+    }
+    this.streams.set(message.streamId, active)
+    const done = this.pump(message.streamId, message.endpoint, message.payload, active)
+    active.done = done
+    const remove = (): void => { this.streams.delete(message.streamId) }
+    void done.then(remove, remove)
+  }
+
+  private async pump(
+    streamId: string,
+    endpoint: string,
+    payload: unknown,
+    active: ActiveStream,
+  ): Promise<void> {
+    try {
+      const source = await this.open(endpoint, payload, active.abort.signal)
+      for await (const value of source) {
+        await this.send({ type: 'item', streamId, value })
+      }
+      if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
+    } catch (error) {
+      if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) {
+        try {
+          await this.send({ type: 'error', streamId, error: this.failure(error) })
+        } catch {
+          // A terminal frame that cannot be encoded or written leaves the
+          // logical stream ambiguous, so fail the physical generation.
+          this.socket.close(1011, 'Remote stream failure could not be delivered')
+        }
+      }
+    }
+  }
+
+  private send(message: RemoteStreamServerMessage): Promise<void> {
+    let text: string
+    try {
+      text = JSON.stringify(message)
+    } catch (cause) {
+      return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause }))
+    }
+    const delivery = this.writes.then(() => new Promise<void>((resolve, reject) => {
+      if (this.socket.readyState !== WebSocket.OPEN) {
+        reject(new Error('api gateway: Remote stream socket is closed'))
+        return
+      }
+      this.socket.send(text, (error) => {
+        if (error) reject(error)
+        else resolve()
+      })
+    }))
+    this.writes = delivery.catch(() => undefined)
+    return delivery
+  }
+}
+
+function rawText(data: RawData): string {
+  if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
+  if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
+  return Buffer.from(data).toString('utf8')
+}
+
+/**
+ * Reject an upgrade without transferring socket ownership to ws.
+ * @param socket - carrier socket that receives the HTTP rejection.
+ */
+export function rejectRemoteStreamUpgrade(socket: Duplex): void {
+  socket.end([
+    'HTTP/1.1 403 Forbidden',
+    'Connection: close',
+    'Content-Type: text/plain; charset=utf-8',
+    'Content-Length: 9',
+    '',
+    'forbidden',
+  ].join('\r\n'))
+}

+ 98 - 0
packages/api/gateway/src/types.ts

@@ -3,6 +3,8 @@
  * @module @deepseek-ai/dsh-api-gateway/types
  * @module @deepseek-ai/dsh-api-gateway/types
  */
  */
 
 
+import type { Context } from '@deepseek-ai/cordis'
+
 /** One Remote method request after a carrier has decoded its envelope. */
 /** One Remote method request after a carrier has decoded its envelope. */
 export interface InvokeRemoteRequest {
 export interface InvokeRemoteRequest {
   /** Remote namespace selected by the generated descriptor. */
   /** Remote namespace selected by the generated descriptor. */
@@ -15,6 +17,85 @@ export interface InvokeRemoteRequest {
   readonly signal?: AbortSignal
   readonly signal?: AbortSignal
 }
 }
 
 
+/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */
+export interface TypertRemoteEventFrame {
+  /** Original Host Cordis event name. */
+  readonly event: string
+  /** Original event argument list after the owner validates it for JSON transport. */
+  readonly args: readonly unknown[]
+}
+
+/** Live Host values used to project one scoped Remote Event. */
+export interface TypertRemoteEventContext {
+  /** Live Host Context identified by the registered Host adapters. */
+  readonly value: Context
+  /** Agent object carried directly by the waterfall request. */
+  readonly subject: object
+}
+
+/** Result returned from a Client waterfall, or delegation back to the Host chain. */
+export type TypertRemoteEventOutcome =
+  | { readonly kind: 'result'; readonly value: unknown }
+  | { readonly kind: 'next' }
+
+/**
+ * One scoped waterfall invocation yielded by the application event source.
+ * The Gateway alone assigns transport ids and resolves the continuation after
+ * a Client result or explicit delegation.
+ */
+export interface TypertRemoteEventInvocation {
+  /** Original Host Cordis event name. */
+  readonly event: string
+  /** Sole request argument before the waterfall's `next()` callback. */
+  readonly request: object
+  readonly context: TypertRemoteEventContext
+  /** Resume the source's Cordis listener with a Client result or `next()`. */
+  readonly resolve: (outcome: TypertRemoteEventOutcome) => void
+  /** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */
+  readonly reject: (reason: unknown) => void
+}
+
+/** Notification or scoped waterfall accepted from the sole Remote Event source. */
+export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation
+
+/**
+ * Open the application-selected event stream for one Client carrier. The
+ * factory must attach all incremental Host listeners before it returns; the
+ * Gateway publishes its readiness item immediately afterward.
+ * @param signal - cancellation shared with the Client stream and registration.
+ * @returns the long-lived stream of notifications and scoped waterfall invocations.
+ */
+export type TypertRemoteEventSource = (
+  signal: AbortSignal,
+) => AsyncIterable<TypertRemoteEventDispatch>
+
+/** Carrier-facing access to decoded Remote streams and their stable failures. */
+export interface TypertGatewayWireStream {
+  /**
+   * Open one logical stream from its wire endpoint and payload.
+   * @param endpoint - canonical Remote endpoint or Gateway-owned stream name.
+   * @param payload - decoded carrier payload.
+   * @param signal - logical-stream cancellation.
+   * @returns validated stream values.
+   */
+  readonly open: (
+    endpoint: string,
+    payload: unknown,
+    signal: AbortSignal,
+  ) => Promise<AsyncIterable<unknown>>
+
+  /**
+   * Convert a stream failure to the carrier-safe Remote failure fields.
+   * @param error - failure raised while opening or consuming a stream.
+   * @returns stable code, message, and details for the Client.
+   */
+  readonly failure: (error: unknown) => {
+    readonly code: string
+    readonly message: string
+    readonly details: object
+  }
+}
+
 /** Stable infrastructure and boundary failures emitted before or after business execution. */
 /** Stable infrastructure and boundary failures emitted before or after business execution. */
 export type TypertGatewayErrorCode =
 export type TypertGatewayErrorCode =
   | 'ambiguous-endpoint'
   | 'ambiguous-endpoint'
@@ -37,6 +118,16 @@ export type TypertGatewayErrorCode =
 
 
 /** Host dispatcher consumed by Connection adapters. */
 /** Host dispatcher consumed by Connection adapters. */
 export interface TypertGateway {
 export interface TypertGateway {
+  /** Carrier adapter shared by WebSocket and in-process transports. */
+  readonly wireStream: TypertGatewayWireStream
+
+  /**
+   * Register the application-selected forwarded-event source.
+   * @param source - stream factory installed by the Remote assembly.
+   * @returns disposer removing this exact source and cancelling its active streams.
+   */
+  registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>
+
   /**
   /**
    * Invoke one live Remote method without assuming a carrier or response envelope.
    * Invoke one live Remote method without assuming a carrier or response envelope.
    * @param request - decoded endpoint and named wire arguments.
    * @param request - decoded endpoint and named wire arguments.
@@ -44,6 +135,13 @@ export interface TypertGateway {
    * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
    * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
    */
    */
   invoke(request: InvokeRemoteRequest): Promise<unknown>
   invoke(request: InvokeRemoteRequest): Promise<unknown>
+
+  /**
+   * Open one live stream Remote method without assuming a physical carrier.
+   * @param request - decoded endpoint and named wire arguments.
+   * @returns an iterable whose items have passed the generated result codec.
+   */
+  stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
 }
 }
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {

+ 303 - 0
packages/api/gateway/tests/control-retry.client.spec.ts

@@ -0,0 +1,303 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
+import {
+  RemoteStreamCarrierError,
+  RemoteStream,
+} from '../src/client/index.ts'
+
+const DESCRIPTION = {
+  version: 'fixture',
+  cwd: '/fixture',
+  attachedSessions: 0,
+  home: '/home/fixture',
+  canOpenPath: true,
+}
+
+function hostSource(initiallyAvailable: boolean): {
+  connection: Pick<ConnectionHandle, 'hostDescription'>
+  publish(available: boolean): void
+} {
+  let current = initiallyAvailable ? DESCRIPTION : undefined
+  const listeners = new Set<() => void>()
+  return {
+    connection: {
+      hostDescription: {
+        getSnapshot: () => current,
+        subscribe: (listener) => {
+          listeners.add(listener)
+          return () => { listeners.delete(listener) }
+        },
+      },
+    },
+    publish: (available) => {
+      current = available ? DESCRIPTION : undefined
+      for (const listener of listeners) listener()
+    },
+  }
+}
+
+interface Generation<Item> {
+  readonly values?: readonly Item[]
+  readonly terminal?: Error
+  readonly hold?: boolean
+  readonly afterAbortError?: Error
+  readonly close?: () => Promise<void>
+}
+
+function scripted<Item>(generations: Generation<Item>[], opened?: () => void) {
+  return (signal: AbortSignal): AsyncIterable<Item> => ({
+    async * [Symbol.asyncIterator](): AsyncIterator<Item> {
+      const generation = generations.shift()
+      if (generation === undefined) throw new Error('fixture has no stream generation')
+      opened?.()
+      try {
+        for (const value of generation.values ?? []) yield value
+        if (generation.terminal !== undefined) throw generation.terminal
+        if (generation.hold === true && !signal.aborted) {
+          await new Promise<void>((resolve) => {
+            signal.addEventListener('abort', () => { resolve() }, { once: true })
+          })
+        }
+        if (generation.afterAbortError !== undefined) throw generation.afterAbortError
+      } finally {
+        await generation.close?.()
+      }
+    },
+  })
+}
+
+function supervisor<Item>(
+  connection: Pick<ConnectionHandle, 'hostDescription'>,
+  generations: Generation<Item>[],
+  carrierFailed?: (error: RemoteStreamCarrierError) => void,
+): RemoteStream<Item> {
+  return new RemoteStream(connection, {
+    name: 'fixture stream',
+    open: scripted(generations),
+    ended: accepted => accepted
+      ? new RemoteStreamCarrierError('accepted generation ended')
+      : new Error('generation ended before acceptance'),
+    ...(carrierFailed === undefined ? {} : { carrierFailed }),
+  })
+}
+
+describe('RemoteStream', () => {
+  it('annotates replacement generations and resets retry state after acceptance', async () => {
+    const source = hostSource(true)
+    const stream = supervisor(source.connection, [
+      { values: ['first'], terminal: new RemoteStreamCarrierError('first lost') },
+      { values: ['second'], hold: true },
+    ])
+    const iterator = stream[Symbol.asyncIterator]()
+
+    const first = await iterator.next()
+    expect(first).toMatchObject({ done: false, value: { generation: 1, value: 'first' } })
+    if (first.done) throw new Error('fixture generation ended early')
+    first.value.accept()
+    const second = await iterator.next()
+    expect(second).toMatchObject({ done: false, value: { generation: 2, value: 'second' } })
+    if (second.done) throw new Error('fixture replacement ended early')
+    second.value.accept()
+
+    await stream.dispose()
+  })
+
+  it('permits one isolated retry while the Host remains available', async () => {
+    const source = hostSource(true)
+    const first = new RemoteStreamCarrierError('first carrier failure')
+    const repeated = new RemoteStreamCarrierError('isolated retry failed')
+    const carrierFailed = vi.fn<(error: RemoteStreamCarrierError) => void>()
+    const stream = supervisor(source.connection, [
+      { terminal: first },
+      { terminal: repeated },
+    ], carrierFailed)
+
+    await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated)
+    expect(carrierFailed).toHaveBeenNthCalledWith(1, first)
+    expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated)
+  })
+
+  it('waits for a replacement Host generation after observing unavailability', async () => {
+    const source = hostSource(false)
+    let opened = 0
+    const stream = new RemoteStream(source.connection, {
+      name: 'fixture stream',
+      open: scripted([
+        { terminal: new RemoteStreamCarrierError('offline') },
+        { values: ['ready'], hold: true },
+      ], () => { opened++ }),
+      ended: () => new Error('ended'),
+    })
+    const pending = stream[Symbol.asyncIterator]().next()
+    await vi.waitFor(() => { expect(opened).toBe(1) })
+
+    source.publish(false)
+    expect(opened).toBe(1)
+    source.publish(true)
+    await expect(pending).resolves.toMatchObject({
+      done: false,
+      value: { generation: 2, value: 'ready' },
+    })
+    await stream.dispose()
+  })
+
+  it('contains a Host publication during subscription setup', async () => {
+    let reads = 0
+    let disposed = 0
+    const connection = {
+      hostDescription: {
+        getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION,
+        subscribe: (listener: () => void) => {
+          listener()
+          return () => { disposed++ }
+        },
+      },
+    }
+    const stream = supervisor(connection, [
+      { terminal: new RemoteStreamCarrierError('offline') },
+      { values: ['ready'], hold: true },
+    ])
+
+    await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({
+      value: { generation: 2, value: 'ready' },
+    })
+    expect(disposed).toBe(1)
+    await stream.dispose()
+  })
+
+  it('restarts with a fresh physical generation', async () => {
+    const source = hostSource(true)
+    const stream = supervisor(source.connection, [
+      { values: ['first'], hold: true },
+      { values: ['second'], hold: true },
+    ])
+    const iterator = stream[Symbol.asyncIterator]()
+    await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 1, value: 'first' } })
+
+    stream.restart()
+
+    await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 2, value: 'second' } })
+    await stream.dispose()
+  })
+
+  it('drops values and cancellation failures from a replaced generation', async () => {
+    const source = hostSource(true)
+    const stream = supervisor(source.connection, [
+      { values: ['first', 'stale'] },
+      {
+        values: ['second'],
+        hold: true,
+        afterAbortError: new Error('replaced generation cancelled'),
+      },
+      { values: ['third'], hold: true },
+    ])
+    const iterator = stream[Symbol.asyncIterator]()
+    const first = await iterator.next()
+    if (first.done) throw new Error('fixture generation ended early')
+
+    stream.restart()
+    first.value.accept()
+    await expect(iterator.next()).resolves.toMatchObject({
+      value: { generation: 2, value: 'second' },
+    })
+
+    stream.restart()
+    await expect(iterator.next()).resolves.toMatchObject({
+      value: { generation: 3, value: 'third' },
+    })
+    await stream.dispose()
+  })
+
+  it('honors replacement requested by carrier diagnostics', async () => {
+    const source = hostSource(true)
+    const holder: { stream?: RemoteStream<string> } = {}
+    const carrierFailed = vi.fn(() => { holder.stream?.restart() })
+    const stream = supervisor(source.connection, [
+      { terminal: new RemoteStreamCarrierError('replace this generation') },
+      { values: ['ready'], hold: true },
+    ], carrierFailed)
+    holder.stream = stream
+
+    await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({
+      value: { generation: 2, value: 'ready' },
+    })
+    expect(carrierFailed).toHaveBeenCalledOnce()
+    await stream.dispose()
+  })
+
+  it('contains replacement during Host-readiness subscription setup', async () => {
+    const holder: { stream?: RemoteStream<string> } = {}
+    let subscriptions = 0
+    const connection = {
+      hostDescription: {
+        getSnapshot: () => undefined,
+        subscribe: () => {
+          subscriptions++
+          holder.stream?.restart()
+          return () => {}
+        },
+      },
+    }
+    const stream = supervisor(connection, [
+      { terminal: new RemoteStreamCarrierError('offline') },
+      { values: ['ready'], hold: true },
+    ])
+    holder.stream = stream
+
+    await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({
+      value: { generation: 2, value: 'ready' },
+    })
+    expect(subscriptions).toBe(1)
+    await stream.dispose()
+  })
+
+  it('waits for generation cleanup during disposal', async () => {
+    const source = hostSource(true)
+    const release = Promise.withResolvers<undefined>()
+    let closed = false
+    const stream = supervisor(source.connection, [{
+      values: ['ready'],
+      hold: true,
+      close: async () => {
+        await release.promise
+        closed = true
+      },
+    }])
+    const iterator = stream[Symbol.asyncIterator]()
+    await iterator.next()
+    const pending = iterator.next()
+
+    const disposing = stream.dispose()
+    expect(stream.dispose()).toBe(disposing)
+    await Promise.resolve()
+    expect(closed).toBe(false)
+    release.resolve(undefined)
+
+    await expect(disposing).resolves.toBeUndefined()
+    await expect(pending).resolves.toEqual({ done: true, value: undefined })
+    expect(closed).toBe(true)
+  })
+
+  it('uses the domain normal-end classification and permits one consumer', async () => {
+    const source = hostSource(true)
+    const stream = supervisor<string>(source.connection, [{}])
+    const iterator = stream[Symbol.asyncIterator]()
+
+    expect(() => stream[Symbol.asyncIterator]()).toThrow('already has a consumer')
+    await expect(iterator.next()).rejects.toThrow('generation ended before acceptance')
+    await stream.dispose()
+  })
+
+  it('can be disposed before consumption and ignores later restart', async () => {
+    const source = hostSource(true)
+    const stream = supervisor<string>(source.connection, [])
+
+    await stream.dispose()
+    expect(stream.signal.aborted).toBe(true)
+    stream.restart()
+    await expect(stream[Symbol.asyncIterator]().next()).resolves.toEqual({
+      done: true,
+      value: undefined,
+    })
+  })
+})

+ 1046 - 0
packages/api/gateway/tests/gateway-stream.host.spec.ts

@@ -0,0 +1,1046 @@
+import { randomUUID } from 'node:crypto'
+import { once } from 'node:events'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import WebSocket, { type RawData } from 'ws'
+import { Context, Service, symbols } from '@deepseek-ai/cordis'
+import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
+import WebServer from '@deepseek-ai/dsh-host-webserver'
+import {
+  bindTypertRemote,
+  Remote,
+  type InvocationDescriptor,
+  type TypertContextMap,
+  type TypertContextWire,
+  TypertRemoteFailure,
+} from '@deepseek-ai/dsh-typert-protocol'
+import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+import TypertGatewayService, {
+  TypertGatewayError,
+  type TypertRemoteEventDispatch,
+  type TypertRemoteEventInvocation,
+  type TypertRemoteEventOutcome,
+} from '@deepseek-ai/dsh-api-gateway'
+import { z } from 'zod'
+import type {
+  RemoteEventClientId,
+  RemoteEventInvocationFrame,
+} from '../src/stream-protocol.ts'
+
+vi.mock('node:crypto', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:crypto')>()
+  return { ...actual, randomUUID: vi.fn(actual.randomUUID) }
+})
+
+const randomUuid = vi.mocked(randomUUID)
+type AgentWireId = TypertContextWire<TypertContextMap['agent']>
+const agentId = (value: string): AgentWireId => value as AgentWireId
+
+class FeedService extends Service {
+  readonly typertRemote = bindTypertRemote(this, 'feed')
+  readonly signals: AbortSignal[] = []
+  returns = 0
+
+  constructor(ctx: Context) {
+    super(ctx, 'feed')
+  }
+
+  @Remote({ mode: 'stream' })
+  async *follow(label: string, signal: AbortSignal): AsyncIterable<string> {
+    this.signals.push(signal)
+    try {
+      yield `${label}:ready`
+      await new Promise<void>((resolve) => {
+        if (signal.aborted) resolve()
+        else signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    } finally {
+      this.returns += 1
+    }
+  }
+
+  @Remote({ mode: 'stream' })
+  *sync(label: string): Iterable<string> {
+    yield `${label}:one`
+    yield `${label}:two`
+  }
+
+  @Remote({ mode: 'stream' })
+  *invalid(): Iterable<string> {
+    yield 42 as unknown as string
+  }
+
+  @Remote({ mode: 'stream' })
+  *nonJson(): Iterable<unknown> {
+    yield 1n
+  }
+
+  @Remote({ mode: 'stream' })
+  missing(): Iterable<string> {
+    return null as unknown as Iterable<string>
+  }
+
+  @Remote({ mode: 'stream' })
+  *src(label: string): Iterable<string> {
+    yield `${label}:src`
+  }
+
+  @Remote({ mode: 'stream' })
+  abortBeforeOpen(signal: AbortSignal): Iterable<string> {
+    if (signal.aborted) throw new Error('fixture observed pre-open cancellation')
+    return []
+  }
+
+  @Remote({ mode: 'stream' })
+  reject(): Iterable<string> {
+    throw new TypertRemoteFailure({
+      code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false },
+    })
+  }
+
+  @Remote({ mode: 'stream' })
+  rejectWithNonJsonDetails(): Iterable<string> {
+    throw new TypertRemoteFailure({
+      code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n },
+    })
+  }
+
+  unary(label: string): string {
+    return label
+  }
+}
+
+const roots: Context[] = []
+
+class RemoteEventSourceProbe {
+  readonly source = (signal: AbortSignal): AsyncIterable<TypertRemoteEventDispatch> => {
+    this.signal = signal
+    return this.iterate(signal)
+  }
+
+  signal: AbortSignal | undefined
+  private readonly dispatches: TypertRemoteEventDispatch[] = []
+  private wake: (() => void) | undefined
+
+  push(dispatch: TypertRemoteEventDispatch): void {
+    this.dispatches.push(dispatch)
+    this.wake?.()
+    this.wake = undefined
+  }
+
+  private async *iterate(signal: AbortSignal): AsyncGenerator<TypertRemoteEventDispatch> {
+    const aborted = (): void => {
+      this.wake?.()
+      this.wake = undefined
+    }
+    signal.addEventListener('abort', aborted, { once: true })
+    try {
+      while (!signal.aborted) {
+        while (this.dispatches.length > 0) {
+          yield this.dispatches.shift() as TypertRemoteEventDispatch
+        }
+        if (signal.aborted) return
+        await new Promise<void>((resolve) => { this.wake = resolve })
+        this.wake = undefined
+      }
+    } finally {
+      signal.removeEventListener('abort', aborted)
+    }
+  }
+}
+
+interface PendingInvocationProbe {
+  readonly dispatch: TypertRemoteEventInvocation
+  readonly outcome: Promise<TypertRemoteEventOutcome>
+  readonly resolve: (outcome: TypertRemoteEventOutcome) => void
+  readonly reject: (reason: unknown) => void
+}
+
+function pendingInvocation(
+  context: Context,
+  signal?: AbortSignal,
+  prompt = 'ship',
+): PendingInvocationProbe {
+  const subject = { ctx: context }
+  const settled = Promise.withResolvers<TypertRemoteEventOutcome>()
+  const resolve = vi.fn((outcome: TypertRemoteEventOutcome) => {
+    settled.resolve(outcome)
+  })
+  const reject = vi.fn((reason: unknown) => {
+    settled.reject(reason)
+  })
+  return {
+    dispatch: {
+      event: 'fixture/approval',
+      request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) },
+      context: { value: context, subject },
+      resolve,
+      reject,
+    },
+    outcome: settled.promise,
+    resolve,
+    reject,
+  }
+}
+
+afterEach(async () => {
+  randomUuid.mockClear()
+  await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+describe('Typert Remote streams', () => {
+  it('opens decoded carrier payloads through the in-process wire adapter', async () => {
+    const { ctx } = await setup(false)
+    const source = await ctx.typertGateway.wireStream.open(
+      'feed/sync',
+      { args: { label: 'wire' } },
+      new AbortController().signal,
+    )
+
+    await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two'])
+  })
+
+  it('validates Iterable and AsyncIterable items and returns the iterator on cancellation', async () => {
+    const { ctx, service } = await setup(false)
+    const abort = new AbortController()
+    const source = await ctx.typertGateway.stream({
+      namespace: 'feed',
+      method: 'follow',
+      args: { label: 'a' },
+      signal: abort.signal,
+    })
+    const iterator = source[Symbol.asyncIterator]()
+    await expect(iterator.next()).resolves.toEqual({ done: false, value: 'a:ready' })
+    const pending = iterator.next()
+    abort.abort(new Error('fixture cancellation'))
+    await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted')
+    expect(service.signals).toEqual([abort.signal])
+    expect(service.returns).toBe(1)
+
+    await expect(collect(await ctx.typertGateway.stream({
+      namespace: 'feed', method: 'sync', args: { label: 'b' },
+    }))).resolves.toEqual(['b:one', 'b:two'])
+    await expect(collect(await ctx.typertGateway.stream({
+      namespace: 'feed', method: 'invalid', args: {},
+    }))).rejects.toMatchObject({ code: 'result-invalid' })
+    await expect(ctx.typertGateway.stream({
+      namespace: 'feed', method: 'missing', args: {},
+    })).rejects.toMatchObject({ code: 'result-invalid' })
+
+    await expect(collect(await ctx.typertGateway.stream({
+      namespace: 'feed', method: 'src', args: { label: 'c' },
+    }))).resolves.toEqual(['c:src'])
+
+    const abortedBeforeOpen = new AbortController()
+    abortedBeforeOpen.abort(new Error('cancelled before open'))
+    await expect(ctx.typertGateway.stream({
+      namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal,
+    })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted')
+
+    const abortedBeforeIteration = new AbortController()
+    abortedBeforeIteration.abort(new Error('cancelled before iteration'))
+    const preCancelled = await ctx.typertGateway.stream({
+      namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal,
+    })
+    await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted')
+  })
+
+  it('keeps unary and stream invocation modes distinct', async () => {
+    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>)
+    await expect(ctx.typertGateway.stream({
+      namespace: 'feed', method: 'unary', args: { label: 'a' },
+    })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial<TypertGatewayError>)
+  })
+
+  it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
+    const { ctx, service } = await setup(true)
+    const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
+    await once(socket, 'open')
+    const frames: Record<string, unknown>[] = []
+    socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
+
+    sendOpen(socket, 'a', 'feed/follow', { label: 'a' })
+    sendOpen(socket, 'b', 'feed/follow', { label: 'b' })
+    await vi.waitFor(() => {
+      expect(frames).toEqual(expect.arrayContaining([
+        { type: 'item', streamId: 'a', value: 'a:ready' },
+        { type: 'item', streamId: 'b', value: 'b:ready' },
+      ]))
+    })
+    expect(service.signals.map(signal => signal.aborted)).toEqual([false, false])
+    expect(service.returns).toBe(0)
+
+    socket.send(JSON.stringify({ type: 'cancel', streamId: 'a' }))
+    await vi.waitFor(() => { expect(service.returns).toBe(1) })
+    expect(service.signals[0]?.aborted).toBe(true)
+    expect(service.signals[1]?.aborted).toBe(false)
+
+    sendOpen(socket, 'sync', 'feed/sync', { label: 's' })
+    sendOpen(socket, 'invalid', 'feed/invalid', {})
+    sendOpen(socket, 'non-json', 'feed/nonJson', {})
+    sendOpen(socket, 'rejected', 'feed/reject', {})
+    await vi.waitFor(() => {
+      expect(frames.filter(frame => frame.streamId === 'sync')).toEqual([
+        { type: 'item', streamId: 'sync', value: 's:one' },
+        { type: 'item', streamId: 'sync', value: 's:two' },
+        { type: 'end', streamId: 'sync' },
+      ])
+      expect(frames.find(frame => frame.streamId === 'invalid')).toMatchObject({
+        type: 'error', error: { code: 'internal' },
+      })
+      expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({
+        type: 'error', error: { code: 'internal' },
+      })
+      expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({
+        type: 'error',
+        streamId: 'rejected',
+        error: {
+          code: 'fixture-rejected',
+          message: 'fixture rejected the stream',
+          details: { retryable: false },
+        },
+      })
+    })
+
+    const closed = once(socket, 'close')
+    sendOpen(socket, 'broken-error', 'feed/rejectWithNonJsonDetails', {})
+    const closeEvent = await closed
+    expect(closeEvent[0]).toBe(1011)
+    expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
+    await vi.waitFor(() => { expect(service.returns).toBe(2) })
+    expect(service.signals[1]?.aborted).toBe(true)
+  })
+
+  it('carries the registered Remote event source and withdraws its active stream', async () => {
+    const { ctx } = await setup(true)
+    let sourceSignal: AbortSignal | undefined
+    const sourceClosed = vi.fn()
+    const publish = Promise.withResolvers<undefined>()
+    const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => {
+      sourceSignal = signal
+      return (async function *() {
+        try {
+          await publish.promise
+          yield { event: 'fixture/changed', args: ['settings'] }
+          await new Promise<void>((resolve) => {
+            if (signal.aborted) resolve()
+            else signal.addEventListener('abort', () => { resolve() }, { once: true })
+          })
+        } finally {
+          sourceClosed()
+        }
+      })()
+    }
+    const unregister = ctx.typertGateway.registerRemoteEvents(source)
+    expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
+      .toThrow('forwarded Remote event source is already registered')
+
+    const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
+    await once(socket, 'open')
+    const frames: Record<string, unknown>[] = []
+    socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
+    sendOpen(socket, 'events', '$events', {})
+
+    await vi.waitFor(() => {
+      const eventFrames = frames.filter(frame => frame.streamId === 'events')
+      expect(eventFrames).toHaveLength(1)
+      expect(eventFrames[0]).toMatchObject({
+        type: 'item', streamId: 'events', value: { type: 'ready' },
+      })
+      expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
+    })
+    publish.resolve(undefined)
+    await vi.waitFor(() => {
+      const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2)
+      expect(eventFrames).toHaveLength(2)
+      expect(eventFrames[0]).toMatchObject({
+        type: 'item', streamId: 'events', value: { type: 'ready' },
+      })
+      expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
+      expect(eventFrames[1]).toEqual({
+        type: 'item', streamId: 'events', value: {
+          type: 'emit', event: 'fixture/changed', args: ['settings'],
+        },
+      })
+    })
+    expect(sourceSignal?.aborted).toBe(false)
+
+    await unregister()
+    expect(sourceClosed).toHaveBeenCalledOnce()
+    await vi.waitFor(() => {
+      expect(sourceSignal?.aborted).toBe(true)
+      expect(frames).toContainEqual({ type: 'end', streamId: 'events' })
+    })
+
+    const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source)
+    await unregister()
+    expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
+      .toThrow('forwarded Remote event source is already registered')
+    await unregisterReplacement()
+    socket.close()
+  })
+
+  it('rejects a scoped dispatch yielded after its Remote event source is withdrawn', async () => {
+    const { ctx } = await setup(false)
+    const publish = Promise.withResolvers<undefined>()
+    const agent = ctx.extend()
+    const pending = pendingInvocation(agent)
+    const source = (): AsyncIterable<TypertRemoteEventDispatch> => (async function* () {
+      await publish.promise
+      yield pending.dispatch
+    })()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source)
+    const rejected = expect(pending.outcome).rejects.toThrow(
+      'forwarded Remote event source was removed',
+    )
+
+    publish.resolve(undefined)
+    await unregister()
+
+    await rejected
+    expect(pending.reject).toHaveBeenCalledTimes(1)
+    expect(pending.resolve).not.toHaveBeenCalled()
+  })
+
+  it('cancels a pending waterfall when its source rejects during removal', async () => {
+    const { ctx } = await setup(true)
+    const agent = ctx.extend()
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === agent ? agentId('agent-removal') : undefined,
+      resolve: id => id === 'agent-removal' ? agent : undefined,
+    })
+    const pending = pendingInvocation(agent)
+    const rejected = expect(pending.outcome).rejects.toThrow(
+      'forwarded Remote event source was removed',
+    )
+    const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () {
+      yield pending.dispatch
+      await new Promise<void>((resolve) => {
+        if (signal.aborted) resolve()
+        else signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+      throw new Error('fixture source rejected during removal')
+    })())
+    const client = await openEventClient(ctx, 'events-removal')
+    await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
+
+    await unregister()
+    await rejected
+    expect(pending.reject).toHaveBeenCalledTimes(1)
+    expect(pending.resolve).not.toHaveBeenCalled()
+    await vi.waitFor(() => {
+      expect(client.frames).toContainEqual({ type: 'end', streamId: client.streamId })
+    })
+    client.socket.close()
+  })
+
+  it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => {
+    const { ctx } = await setup(false)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+
+    for (const event of [42, ''] as const) {
+      const invalidName = pendingInvocation(ctx)
+      const rejected = expect(invalidName.outcome).rejects.toThrow(
+        'Remote event name must be a nonempty string',
+      )
+      source.push({
+        ...invalidName.dispatch,
+        event: event as unknown as string,
+      })
+      await rejected
+    }
+
+    const unavailable = pendingInvocation(ctx)
+    source.push(unavailable.dispatch)
+    await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' })
+    expect(unavailable.reject).not.toHaveBeenCalled()
+
+    let selected = ctx.extend()
+    let identity: unknown = 1n
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === selected ? identity as AgentWireId : undefined,
+      resolve: () => selected,
+    })
+    const nonJsonIdentity = pendingInvocation(selected)
+    const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow(
+      'require a non-empty Agent identity',
+    )
+    source.push(nonJsonIdentity.dispatch)
+    await nonJsonRejected
+
+    identity = 'agent-invalid-request'
+    const invalidRequest = pendingInvocation(selected)
+    const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow(
+      'must carry its scoped Agent directly',
+    )
+    source.push({
+      ...invalidRequest.dispatch,
+      request: {},
+    })
+    await invalidRequestRejected
+
+    const staleFiber = ctx.plugin(() => {})
+    await staleFiber
+    selected = staleFiber.ctx
+    identity = 'agent-stale'
+    await staleFiber.dispose()
+    const stale = pendingInvocation(selected)
+    source.push(stale.dispatch)
+    await expect(stale.outcome).resolves.toEqual({ kind: 'next' })
+    expect(stale.reject).not.toHaveBeenCalled()
+
+    selected = ctx.extend()
+    identity = 'agent-cancelled'
+    const abort = new AbortController()
+    abort.abort('fixture non-error cancellation')
+    const cancelled = pendingInvocation(selected, abort.signal)
+    const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({
+      message: 'typert gateway: Remote event was cancelled',
+      cause: 'fixture non-error cancellation',
+    })
+    source.push(cancelled.dispatch)
+    await cancelledOutcome
+
+    await unregister()
+  })
+
+  it('rejects notification arguments that are not lossless JSON arrays', async () => {
+    const { ctx } = await setup(false)
+    const frames = [
+      { event: 'fixture/changed', args: {} },
+      { event: 'fixture/changed', args: [1n] },
+    ]
+    for (const frame of frames) {
+      let sourceSignal: AbortSignal | undefined
+      const unregister = ctx.typertGateway.registerRemoteEvents((signal) => {
+        sourceSignal = signal
+        return (async function* () {
+          yield frame as unknown as TypertRemoteEventDispatch
+        })()
+      })
+      await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
+      const reason: unknown = sourceSignal?.reason
+      if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error')
+      expect(reason.message).toContain('arguments are not lossless JSON data')
+      await unregister()
+    }
+  })
+
+  it('retries a colliding Remote event id before publishing the second waterfall', async () => {
+    const { ctx } = await setup(false)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const agent = ctx.extend()
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === agent ? agentId('agent-collision') : undefined,
+      resolve: id => id === 'agent-collision' ? agent : undefined,
+    })
+    const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType<typeof randomUUID>
+    const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType<typeof randomUUID>
+    randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
+    const firstAbort = new AbortController()
+    const secondAbort = new AbortController()
+    const first = pendingInvocation(agent, firstAbort.signal, 'first')
+    const second = pendingInvocation(agent, secondAbort.signal, 'second')
+
+    source.push(first.dispatch)
+    await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
+    source.push(second.dispatch)
+    await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(3) })
+
+    const firstReason = new Error('cancel first collision fixture')
+    const secondReason = new Error('cancel second collision fixture')
+    const firstRejected = expect(first.outcome).rejects.toBe(firstReason)
+    const secondRejected = expect(second.outcome).rejects.toBe(secondReason)
+    firstAbort.abort(firstReason)
+    secondAbort.abort(secondReason)
+    await firstRejected
+    await secondRejected
+    await unregister()
+  })
+
+  it('retries a colliding Remote event Client id before opening the second generation', async () => {
+    const { ctx } = await setup(true)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType<typeof randomUUID>
+    const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType<typeof randomUUID>
+    randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
+
+    const first = await openEventClient(ctx, 'events-client-id-a')
+    const second = await openEventClient(ctx, 'events-client-id-b')
+
+    expect(first.clientId).toBe(firstId)
+    expect(second.clientId).toBe(secondId)
+    expect(randomUuid).toHaveBeenCalledTimes(3)
+    first.socket.close()
+    second.socket.close()
+    await unregister()
+  })
+
+  it('fans one scoped waterfall out and accepts the first Client result', async () => {
+    const { ctx } = await setup(true)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const agent = ctx.extend()
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
+      resolve: id => id === 'agent-1' ? agent : undefined,
+    })
+    const first = await openEventClient(ctx, 'events-a')
+    const second = await openEventClient(ctx, 'events-b')
+    const pending = pendingInvocation(agent)
+    source.push(pending.dispatch)
+
+    await vi.waitFor(() => {
+      expect(deliveredInvocation(first)).toBeDefined()
+      expect(deliveredInvocation(second)).toBeDefined()
+    })
+    const firstFrame = deliveredInvocation(first)!
+    const secondFrame = deliveredInvocation(second)!
+    expect(firstFrame.eventId).toBe(secondFrame.eventId)
+    expect(firstFrame).toMatchObject({
+      type: 'waterfall',
+      event: 'fixture/approval',
+      agentId: 'agent-1',
+      request: { prompt: 'ship' },
+    })
+    expect(firstFrame).not.toHaveProperty('deliveryId')
+    expect(secondFrame).not.toHaveProperty('deliveryId')
+
+    await sendEventResult(second, secondFrame, {
+      kind: 'result', value: 'allowed',
+    })
+    await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
+    await vi.waitFor(() => {
+      expect(first.frames).toContainEqual({
+        type: 'item',
+        streamId: first.streamId,
+        value: { type: 'cancel', eventId: firstFrame.eventId },
+      })
+    })
+
+    await sendEventResult(first, firstFrame, {
+      kind: 'result', value: 'rejected',
+    })
+    expect(pending.resolve).toHaveBeenCalledTimes(1)
+    expect(pending.reject).not.toHaveBeenCalled()
+    first.socket.close()
+    second.socket.close()
+    await unregister()
+  })
+
+  it('rejects the Host waterfall with the first Client listener rejection', async () => {
+    const { ctx } = await setup(true)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const agent = ctx.extend()
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined,
+      resolve: id => id === 'agent-rejected' ? agent : undefined,
+    })
+    const client = await openEventClient(ctx, 'events-rejected')
+    const pending = pendingInvocation(agent)
+    source.push(pending.dispatch)
+    await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
+    const frame = deliveredInvocation(client)!
+    const rejected = expect(pending.outcome).rejects.toMatchObject({
+      name: 'UserQuestionError',
+      message: 'the user cancelled ask_user_question',
+      code: 'ASK_CANCELLED',
+      details: { questionId: 'question-1' },
+    })
+
+    await sendEventResult(client, frame, {
+      kind: 'rejected',
+      error: {
+        name: 'UserQuestionError',
+        message: 'the user cancelled ask_user_question',
+        code: 'ASK_CANCELLED',
+        details: { questionId: 'question-1' },
+      },
+    })
+    await rejected
+    expect(pending.reject).toHaveBeenCalledTimes(1)
+    expect(pending.resolve).not.toHaveBeenCalled()
+
+    client.socket.close()
+    await unregister()
+  })
+
+  it('delegates to the Host only after every active Client returns next', async () => {
+    const { ctx } = await setup(true)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const agent = ctx.extend()
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
+      resolve: id => id === 'agent-1' ? agent : undefined,
+    })
+    const first = await openEventClient(ctx, 'events-next-a')
+    const second = await openEventClient(ctx, 'events-next-b')
+    const pending = pendingInvocation(agent)
+    source.push(pending.dispatch)
+    await vi.waitFor(() => {
+      expect(deliveredInvocation(first)).toBeDefined()
+      expect(deliveredInvocation(second)).toBeDefined()
+    })
+    const firstFrame = deliveredInvocation(first)!
+    const secondFrame = deliveredInvocation(second)!
+
+    await sendEventResult(first, firstFrame, { kind: 'next' })
+    expect(pending.resolve).not.toHaveBeenCalled()
+    await sendEventResult(second, secondFrame, { kind: 'next' })
+    await expect(pending.outcome).resolves.toEqual({ kind: 'next' })
+    expect(pending.resolve).toHaveBeenCalledTimes(1)
+    expect(pending.reject).not.toHaveBeenCalled()
+    first.socket.close()
+    second.socket.close()
+    await unregister()
+  })
+
+  it('replays a pending event id to a replacement Client generation', async () => {
+    const { ctx } = await setup(true)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const agent = ctx.extend()
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
+      resolve: id => id === 'agent-1' ? agent : undefined,
+    })
+    const original = await openEventClient(ctx, 'events-original')
+    const pending = pendingInvocation(agent)
+    source.push(pending.dispatch)
+    await vi.waitFor(() => { expect(deliveredInvocation(original)).toBeDefined() })
+    const originalFrame = deliveredInvocation(original)!
+    const closed = once(original.socket, 'close')
+    original.socket.close()
+    await closed
+
+    const replacement = await openEventClient(ctx, 'events-replacement')
+    await vi.waitFor(() => { expect(deliveredInvocation(replacement)).toBeDefined() })
+    const replayed = deliveredInvocation(replacement)!
+    expect(replayed.eventId).toBe(originalFrame.eventId)
+    expect(replayed).not.toHaveProperty('deliveryId')
+    await sendEventResult(replacement, replayed, {
+      kind: 'result', value: 'allowed',
+    })
+    await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
+
+    replacement.socket.close()
+    await unregister()
+  })
+
+  it('cancels pending deliveries when the Host signal or Context ends', async () => {
+    const { ctx } = await setup(true)
+    const source = new RemoteEventSourceProbe()
+    const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
+    const signalAgent = ctx.extend()
+    const contextFiber = ctx.plugin(() => {})
+    await contextFiber
+    const contextAgent = contextFiber.ctx
+    ctx.typert.contexts.registerHost('agent', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: (candidate) => {
+        if (candidate === signalAgent) return agentId('agent-signal')
+        if (candidate === contextAgent) return agentId('agent-context')
+        return undefined
+      },
+      resolve: (id) => {
+        if (id === 'agent-signal') return signalAgent
+        if (id === 'agent-context') return contextAgent
+        return undefined
+      },
+    })
+    const client = await openEventClient(ctx, 'events-cancel')
+
+    const abort = new AbortController()
+    const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal')
+    source.push(signalPending.dispatch)
+    await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
+    const signalFrame = deliveredInvocation(client)!
+    expect(signalFrame).toMatchObject({
+      type: 'waterfall',
+      agentId: 'agent-signal',
+      request: { prompt: 'signal' },
+    })
+    const signalReason = new Error('Host caller cancelled')
+    const signalOutcome = expect(signalPending.outcome).rejects.toBe(signalReason)
+    abort.abort(signalReason)
+    await signalOutcome
+    await vi.waitFor(() => {
+      expect(client.frames).toContainEqual({
+        type: 'item',
+        streamId: client.streamId,
+        value: { type: 'cancel', eventId: signalFrame.eventId },
+      })
+    })
+
+    const contextPending = pendingInvocation(contextAgent, undefined, 'context')
+    source.push(contextPending.dispatch)
+    let contextFrame: RemoteEventInvocationFrame | undefined
+    await vi.waitFor(() => {
+      contextFrame = client.frames
+        .filter(frame => frame.type === 'item' && frame.streamId === client.streamId)
+        .map(frame => frame.value)
+        .find(value => typeof value === 'object'
+          && value !== null
+          && Reflect.get(value, 'event') === 'fixture/approval'
+          && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined
+      expect(contextFrame).toBeDefined()
+    })
+    const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released')
+    await contextFiber.dispose()
+    await contextOutcome
+    await vi.waitFor(() => {
+      expect(client.frames).toContainEqual({
+        type: 'item',
+        streamId: client.streamId,
+        value: { type: 'cancel', eventId: contextFrame!.eventId },
+      })
+    })
+
+    client.socket.close()
+    await unregister()
+  })
+
+  it('validates the internal Remote event request and reports an absent source', async () => {
+    const { ctx } = await setup(true)
+    const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
+    await once(socket, 'open')
+    const frames: Record<string, unknown>[] = []
+    socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
+
+    sendOpen(socket, 'missing', '$events', {})
+    await vi.waitFor(() => {
+      expect(frames.find(frame => frame.streamId === 'missing')?.type).toBe('error')
+      expect(streamErrorMessage(frames, 'missing')).toContain('source is unavailable')
+    })
+
+    let sourceCalls = 0
+    const unregister = ctx.typertGateway.registerRemoteEvents(() => {
+      sourceCalls += 1
+      return (async function *(): AsyncIterable<never> {})()
+    })
+    const invalidPayloads: readonly unknown[] = [
+      null,
+      [],
+      {},
+      { other: {} },
+      { args: null },
+      { args: [] },
+      { args: { extra: true } },
+    ]
+    invalidPayloads.forEach((payload, index) => {
+      socket.send(JSON.stringify({
+        type: 'open', streamId: `invalid-${String(index)}`, endpoint: '$events', payload,
+      }))
+    })
+    await vi.waitFor(() => {
+      expect(frames.filter(frame => String(frame.streamId).startsWith('invalid-'))).toHaveLength(invalidPayloads.length)
+    })
+    for (const [index] of invalidPayloads.entries()) {
+      const streamId = `invalid-${String(index)}`
+      expect(frames.find(frame => frame.streamId === streamId)?.type).toBe('error')
+      expect(streamErrorMessage(frames, streamId)).toContain('requires an empty args object')
+    }
+    expect(sourceCalls).toBe(1)
+
+    await unregister()
+    socket.close()
+  })
+
+  it('applies Connection trusted-host policy before accepting the Gateway socket', async () => {
+    const { ctx } = await setup(true)
+    const socket = new WebSocket(
+      `ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`,
+      { headers: { host: 'untrusted.example' } },
+    )
+    socket.on('error', () => {})
+    const responseEvent: unknown[] = await once(socket, 'unexpected-response')
+    const request = responseEvent[0]
+    const response = responseEvent[1]
+    const rejected = response as { statusCode?: number; resume(): void }
+    expect(rejected.statusCode).toBe(403)
+    rejected.resume()
+    ;(request as { abort(): void }).abort()
+  })
+})
+
+async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
+  const ctx = new Context()
+  roots.push(ctx)
+  if (transport) {
+    await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 })
+  }
+  await ctx.plugin(TypertRegistry)
+  await ctx.plugin(TypertGatewayService)
+  if (transport) {
+    await ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
+  }
+  await ctx.plugin(FeedService)
+  ctx.typert.register({
+    package: '@fixture/feed',
+    face: 'host',
+    schemas: [],
+    model: { services: [], events: [], objects: [] },
+    invocations: descriptors(),
+  })
+  const receiver = ctx.get('feed') as unknown as FeedService & { [symbols.original]?: FeedService }
+  return { ctx, service: receiver[symbols.original] ?? receiver }
+}
+
+function descriptors(): InvocationDescriptor[] {
+  const label = {
+    name: 'label',
+    wire: 'label',
+    source: 'json' as const,
+    codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', schema: z.string() },
+  }
+  const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({
+    id: `@fixture/feed#feed/${method}`,
+    service: 'feed',
+    namespace: 'feed',
+    method,
+    mode: 'stream',
+    invocation: { kind: 'direct' },
+    parameters,
+    result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema },
+  })
+  return [
+    { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
+    stream('sync', [label], z.string()),
+    stream('invalid', [], z.string()),
+    stream('nonJson', [], z.unknown()),
+    stream('missing', [], z.string()),
+    { ...stream('abortBeforeOpen', [], z.string()), cancellation: { parameter: 'signal' } },
+    stream('reject', [], z.string()),
+    stream('rejectWithNonJsonDetails', [], z.string()),
+    {
+      id: '@fixture/feed#feed/unary',
+      service: 'feed',
+      namespace: 'feed',
+      method: 'unary',
+      invocation: { kind: 'direct' },
+      parameters: [label],
+      result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema: z.string() },
+    },
+  ]
+}
+
+interface RemoteEventTestClient {
+  readonly socket: WebSocket
+  readonly frames: Record<string, unknown>[]
+  readonly streamId: string
+  readonly clientId: RemoteEventClientId
+  readonly origin: string
+}
+
+async function openEventClient(ctx: Context, streamId: string): Promise<RemoteEventTestClient> {
+  const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
+  const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`)
+  await once(socket, 'open')
+  const frames: Record<string, unknown>[] = []
+  socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
+  sendOpen(socket, streamId, '$events', {})
+  let clientId: RemoteEventClientId | undefined
+  await vi.waitFor(() => {
+    const ready = frames.find(frame => frame.type === 'item'
+      && frame.streamId === streamId
+      && typeof frame.value === 'object'
+      && frame.value !== null
+      && Reflect.get(frame.value, 'type') === 'ready')
+    const candidate: unknown = ready === undefined ? undefined : Reflect.get(ready.value as object, 'clientId')
+    expect(typeof candidate).toBe('string')
+    if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId
+  })
+  if (clientId === undefined) throw new Error('Remote event stream omitted its Client id')
+  return { socket, frames, streamId, clientId, origin }
+}
+
+function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined {
+  for (const frame of client.frames) {
+    if (frame.type !== 'item' || frame.streamId !== client.streamId) continue
+    const value = frame.value
+    if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'eventId')) continue
+    return value as RemoteEventInvocationFrame
+  }
+  return undefined
+}
+
+async function sendEventResult(
+  client: RemoteEventTestClient,
+  frame: RemoteEventInvocationFrame,
+  outcome:
+    | { readonly kind: 'next' }
+    | { readonly kind: 'result'; readonly value?: unknown }
+    | {
+      readonly kind: 'rejected'
+      readonly error: {
+        readonly name: string
+        readonly message: string
+        readonly code?: string
+        readonly details?: unknown
+      }
+    },
+): Promise<void> {
+  const rpcId = `remote-event-result-${client.streamId}`
+  const response = await fetch(`${client.origin}/api/$events/result`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({
+      type: 'client-request',
+      rpcId,
+      method: '$events/result',
+      payload: {
+        args: { clientId: client.clientId, eventId: frame.eventId, outcome },
+      },
+    }),
+  })
+  expect(response.status).toBe(200)
+  const body = await response.json() as { readonly result?: { readonly ok?: boolean; readonly error?: { message?: string } } }
+  if (body.result?.ok !== true) {
+    throw new Error(body.result?.error?.message ?? 'Remote event result failed')
+  }
+}
+
+function sendOpen(socket: WebSocket, streamId: string, endpoint: string, args: object): void {
+  socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }))
+}
+
+function rawText(data: RawData): string {
+  if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
+  if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
+  return Buffer.from(data).toString('utf8')
+}
+
+function streamErrorMessage(frames: readonly Record<string, unknown>[], streamId: string): string | undefined {
+  const error = frames.find(frame => frame.streamId === streamId)?.error
+  if (typeof error !== 'object' || error === null) return undefined
+  const message = Reflect.get(error, 'message') as unknown
+  return typeof message === 'string' ? message : undefined
+}
+
+async function collect(source: AsyncIterable<unknown>): Promise<unknown[]> {
+  const values: unknown[] = []
+  for await (const value of source) values.push(value)
+  return values
+}

+ 1542 - 71
packages/api/gateway/tests/gateway.client.spec.ts

@@ -2,18 +2,38 @@ import { Context, Service } from '@deepseek-ai/cordis'
 import type { Fiber } from '@deepseek-ai/cordis'
 import type { Fiber } from '@deepseek-ai/cordis'
 import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 import { z } from 'zod'
 import { z } from 'zod'
-import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import {
+  apply as applyConnection,
+  type ConnectionGenerationSource,
+  type ConnectionHandle,
+} from '@deepseek-ai/dsh-client-connection/client'
 import type {
 import type {
   InvocationDescriptor,
   InvocationDescriptor,
   RemoteResult,
   RemoteResult,
-  TypertClientRemote,
+  TypertContextMap,
+  TypertContextWire,
   TypertContext,
   TypertContext,
+  TypertLookup,
   TypertRemoteScopeApi,
   TypertRemoteScopeApi,
   TypertRemoteNamespace,
   TypertRemoteNamespace,
 } from '@deepseek-ai/dsh-typert-protocol'
 } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import type { ClientRemote } from '../src/client/index.ts'
 import type { ClientRemote } from '../src/client/index.ts'
-import { apply, inject } from '../src/client/index.ts'
+import { apply, inject, RemoteStream } from '../src/client/index.ts'
+import {
+  RemoteStreamCarrierError,
+  RemoteStreamError,
+  RemoteStreamMuxClient,
+} from '../src/client/stream-client.ts'
+
+type FixtureApprovalOutcome = 'allowed' | 'unavailable'
+const fixtureContextTag = Symbol('fixture-context-tag')
+type AgentWireId = TypertContextWire<TypertContextMap['agent']>
+const agentId = (value: string): AgentWireId => value as AgentWireId
+
+interface FixtureAgent {
+  readonly agentId: string
+}
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
   interface Events {
   interface Events {
@@ -27,6 +47,21 @@ declare module '@deepseek-ai/cordis' {
      * @param count - marker payload never observed.
      * @param count - marker payload never observed.
      */
      */
     'fixture/idle'(count: number): void
     'fixture/idle'(count: number): void
+    /**
+     * Test-only scoped waterfall forwarded through the existing Remote Event stream.
+     * @param request - JSON-safe request payload.
+     * @param next - delegates to the next Client listener or Host waterfall.
+     * @returns the claimed or delegated outcome.
+     */
+    'fixture/approval'(
+      this: Context,
+      request: {
+        readonly prompt: string
+        readonly agent: FixtureAgent
+        readonly signal?: AbortSignal
+      },
+      next: () => Promise<FixtureApprovalOutcome>,
+    ): Promise<FixtureApprovalOutcome>
     /**
     /**
      * Test-only event the Host assembly does not forward.
      * Test-only event the Host assembly does not forward.
      * @param flag - marker payload never delivered.
      * @param flag - marker payload never delivered.
@@ -36,12 +71,17 @@ declare module '@deepseek-ai/cordis' {
 }
 }
 
 
 declare module '@deepseek-ai/dsh-typert-protocol' {
 declare module '@deepseek-ai/dsh-typert-protocol' {
-  interface TypertRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
+  interface TypertRemoteEventSelection extends
+    Record<'fixture/changed' | 'fixture/idle' | 'fixture/approval', true> {}
 
 
   interface TypertContextMap {
   interface TypertContextMap {
     fixture: TypertContext<string>
     fixture: TypertContext<string>
   }
   }
 
 
+  interface TypertLookupMap {
+    fixture: TypertLookup<FixtureAgent, string>
+  }
+
   interface TypertRemoteMap {
   interface TypertRemoteMap {
     'probe/create': (
     'probe/create': (
       agentId: string,
       agentId: string,
@@ -49,6 +89,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
       signal?: AbortSignal,
       signal?: AbortSignal,
     ) => Promise<RemoteResult<{ readonly ref: string }>>
     ) => Promise<RemoteResult<{ readonly ref: string }>>
     'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
     'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
+    'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable<string>
   }
   }
 
 
   interface TypertRemoteScopeMap {
   interface TypertRemoteScopeMap {
@@ -68,13 +109,19 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
 }
 }
 
 
 type FixtureContext = Omit<Context, 'remote'> & {
 type FixtureContext = Omit<Context, 'remote'> & {
-  readonly remote: TypertClientRemote & TypertRemoteScopeApi<'fixture'>
+  readonly remote: ClientRemote & TypertRemoteScopeApi<'fixture'>
 }
 }
 
 
 // Compile-time contract of `$on`: the key face is the forwarding selection and
 // Compile-time contract of `$on`: the key face is the forwarding selection and
 // the listener signature is the owning package's own Cordis declaration.
 // the listener signature is the owning package's own Cordis declaration.
 function remoteEventContracts(remote: ClientRemote): void {
 function remoteEventContracts(remote: ClientRemote): void {
   remote.$on('fixture/changed', (namespace) => { void namespace })
   remote.$on('fixture/changed', (namespace) => { void namespace })
+  remote.$on('fixture/approval', async function (request, next) {
+    expectTypeOf(this).toEqualTypeOf<Context>()
+    expectTypeOf(request.agent).toEqualTypeOf<Context>()
+    expectTypeOf(request.signal).toEqualTypeOf<AbortSignal | undefined>()
+    return request.prompt === '' ? next() : 'allowed'
+  })
   // @ts-expect-error -- declared in Events but outside the forwarding selection.
   // @ts-expect-error -- declared in Events but outside the forwarding selection.
   remote.$on('fixture/unselected', () => {})
   remote.$on('fixture/unselected', () => {})
   // @ts-expect-error -- not declared in Events at all.
   // @ts-expect-error -- not declared in Events at all.
@@ -155,22 +202,390 @@ function maybeDescriptor(): InvocationDescriptor {
   }
   }
 }
 }
 
 
-async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
-  const { ctx } = await benchFiber(call)
+function streamDescriptor(): InvocationDescriptor {
+  return {
+    id: '@fixture/probe#probe/watch',
+    service: 'probe',
+    namespace: 'probe',
+    method: 'watch',
+    mode: 'stream',
+    invocation: { kind: 'direct' },
+    parameters: [{
+      name: 'topic',
+      wire: 'topic',
+      source: 'json',
+      codec: { mode: 'strict', typeSymbol: '@fixture#Topic', schema: z.string().min(1) },
+    }],
+    cancellation: { parameter: 'signal' },
+    result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', schema: z.string().min(1) },
+  }
+}
+
+type WebSocketGlobal = { WebSocket?: typeof WebSocket }
+
+class FakeWebSocket extends EventTarget {
+  static readonly CONNECTING = 0
+  static readonly OPEN = 1
+  static readonly CLOSING = 2
+  static readonly CLOSED = 3
+  static readonly sockets: FakeWebSocket[] = []
+  static autoOpen = true
+  static dispatchClose = true
+
+  readonly url: string
+  readonly sent: string[] = []
+  readonly closedWith: { readonly code?: number; readonly reason?: string }[] = []
+  readyState = FakeWebSocket.CONNECTING
+
+  constructor(url: string | URL) {
+    super()
+    this.url = String(url)
+    FakeWebSocket.sockets.push(this)
+    queueMicrotask(() => {
+      if (FakeWebSocket.autoOpen) this.open()
+    })
+  }
+
+  open(): void {
+    if (this.readyState !== FakeWebSocket.CONNECTING) return
+    this.readyState = FakeWebSocket.OPEN
+    this.dispatchEvent(new Event('open'))
+  }
+
+  fail(): void {
+    this.dispatchEvent(new Event('error'))
+  }
+
+  send(data: string): void {
+    if (this.readyState !== FakeWebSocket.OPEN) throw new Error('fixture socket is not open')
+    this.sent.push(data)
+  }
+
+  close(code?: number, reason?: string): void {
+    this.closedWith.push({
+      ...(code === undefined ? {} : { code }),
+      ...(reason === undefined ? {} : { reason }),
+    })
+    if (this.readyState === FakeWebSocket.CLOSED) return
+    if (!FakeWebSocket.dispatchClose) {
+      this.readyState = FakeWebSocket.CLOSING
+      return
+    }
+    this.drop()
+  }
+
+  drop(): void {
+    if (this.readyState === FakeWebSocket.CLOSED) return
+    this.readyState = FakeWebSocket.CLOSED
+    this.dispatchEvent(new Event('close'))
+  }
+
+  receive(value: unknown): void {
+    this.receiveRaw(typeof value === 'string' ? value : JSON.stringify(value))
+  }
+
+  receiveRaw(data: unknown): void {
+    this.dispatchEvent(new MessageEvent('message', {
+      data,
+    }))
+  }
+}
+
+async function bench(
+  call: ConnectionHandle['rpc']['call'],
+  carrier: 'in-process' | 'web' = 'in-process',
+): Promise<Context> {
+  const { ctx } = await benchFiber(call, carrier)
   return ctx
   return ctx
 }
 }
 
 
 async function benchFiber(
 async function benchFiber(
   call: ConnectionHandle['rpc']['call'],
   call: ConnectionHandle['rpc']['call'],
-): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
+  carrier: 'in-process' | 'web' = 'in-process',
+  open: NonNullable<ConnectionHandle['rpc']['open']> = () => unexpectedInProcessStream(),
+): Promise<{
+  readonly ctx: Context
+  readonly client: Fiber
+  readonly generation: GenerationHarness
+}> {
+  const ctx = new Context()
+  await ctx.plugin(TypertRegistry)
+  const rpc = carrier === 'web'
+    ? { call }
+    : { call, open }
+  const generation = new GenerationHarness()
+  ctx.provide('connection', {
+    rpc,
+    registerGenerationSource: generation.register,
+    start: () => ({ stop: () => {} }),
+  } as unknown as ConnectionHandle)
+  const client = ctx.plugin({ inject, apply })
+  await client
+  return { ctx, client, generation }
+}
+
+async function *unexpectedInProcessStream(): AsyncGenerator<never> {
+  throw new Error('fixture did not install an in-process stream')
+}
+
+interface GenerationRun {
+  readonly signal: AbortSignal
+  readonly ready: Promise<void>
+  readonly done: Promise<void>
+  abort(reason?: unknown): void
+}
+
+class GenerationHarness {
+  private source: ConnectionGenerationSource | undefined
+  private active: AbortController | undefined
+
+  readonly register = (source: ConnectionGenerationSource): (() => void) => {
+    if (this.source !== undefined) throw new Error('fixture generation source already registered')
+    this.source = source
+    return () => {
+      if (this.source !== source) return
+      this.source = undefined
+      this.active?.abort(new Error('fixture generation source removed'))
+      this.active = undefined
+    }
+  }
+
+  start(): GenerationRun {
+    if (this.source === undefined) throw new Error('fixture generation source is not registered')
+    if (this.active !== undefined) throw new Error('fixture generation is already active')
+    const source = this.source
+    const controller = new AbortController()
+    this.active = controller
+    let reportReady!: () => void
+    const ready = new Promise<void>((resolve) => { reportReady = resolve })
+    const done = Promise.resolve()
+      .then(() => source(controller.signal, reportReady))
+      .finally(() => {
+        if (this.active === controller) this.active = undefined
+      })
+    void done.catch(() => undefined)
+    return {
+      signal: controller.signal,
+      ready,
+      done,
+      abort: (reason) => { controller.abort(reason) },
+    }
+  }
+
+  startOverlapping(): GenerationRun {
+    if (this.source === undefined) throw new Error('fixture generation source is not registered')
+    const controller = new AbortController()
+    let reportReady!: () => void
+    const ready = new Promise<void>((resolve) => { reportReady = resolve })
+    const done = Promise.resolve().then(() => this.source?.(controller.signal, reportReady))
+      .then(() => undefined)
+    void done.catch(() => undefined)
+    return {
+      signal: controller.signal,
+      ready,
+      done,
+      abort: (reason) => { controller.abort(reason) },
+    }
+  }
+}
+
+function deferredReadiness(): {
+  readonly promise: Promise<void>
+  readonly resolve: () => void
+  readonly reject: (error: unknown) => void
+} {
+  let resolve!: () => void
+  let reject!: (error: unknown) => void
+  const promise = new Promise<void>((accept, decline) => {
+    resolve = accept
+    reject = decline
+  })
+  return { promise, resolve, reject }
+}
+
+async function loaderReadinessBench(readiness: Promise<unknown>): Promise<{
+  readonly client: Fiber
+  readonly start: ReturnType<typeof vi.fn<ConnectionHandle['start']>>
+  readonly stop: ReturnType<typeof vi.fn<() => void>>
+}> {
   const ctx = new Context()
   const ctx = new Context()
   await ctx.plugin(TypertRegistry)
   await ctx.plugin(TypertRegistry)
-  ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
+  const generation = new GenerationHarness()
+  const stop = vi.fn<() => void>()
+  const start = vi.fn<ConnectionHandle['start']>(() => ({ stop }))
+  ctx.provide('connection', {
+    rpc: {
+      call: vi.fn<ConnectionHandle['rpc']['call']>(),
+      open: () => unexpectedInProcessStream(),
+    },
+    registerGenerationSource: generation.register,
+    start,
+  } as unknown as ConnectionHandle)
+  ctx.provide('loader', { await: () => readiness })
   const client = ctx.plugin({ inject, apply })
   const client = ctx.plugin({ inject, apply })
   await client
   await client
-  return { ctx, client }
+  return { client, start, stop }
+}
+
+type EventStreamItem =
+  | { readonly kind: 'frame'; readonly value: unknown }
+  | { readonly kind: 'end' }
+  | { readonly kind: 'fail'; readonly error: unknown }
+
+interface EventStreamConnection {
+  readonly items: EventStreamItem[]
+  wake: (() => void) | undefined
 }
 }
 
 
+class RemoteEventCarrier {
+  readonly calls: {
+    readonly channel: string
+    readonly endpoint: string
+    readonly payload: unknown
+    readonly signal: AbortSignal
+  }[] = []
+  private readonly connections = new Set<EventStreamConnection>()
+  private nextClient = 1
+
+  get activeConnections(): number {
+    return this.connections.size
+  }
+
+  readonly open: NonNullable<ConnectionHandle['rpc']['open']> = (channel, endpoint, payload, signal) => {
+    this.calls.push({ channel, endpoint, payload, signal })
+    return this.iterate(signal)
+  }
+
+  emit(value: unknown): void {
+    this.feed({ kind: 'frame', value })
+  }
+
+  end(): void {
+    this.feed({ kind: 'end' })
+  }
+
+  fail(error: unknown): void {
+    this.feed({ kind: 'fail', error })
+  }
+
+  private feed(item: EventStreamItem): void {
+    for (const connection of this.connections) {
+      connection.items.push(item)
+      connection.wake?.()
+    }
+  }
+
+  private async *iterate(signal: AbortSignal): AsyncGenerator {
+    signal.throwIfAborted()
+    const clientId = `event-client-${String(this.nextClient++)}`
+    const connection: EventStreamConnection = { items: [], wake: undefined }
+    this.connections.add(connection)
+    const abort = (): void => { connection.wake?.() }
+    signal.addEventListener('abort', abort, { once: true })
+    try {
+      yield { type: 'ready', clientId }
+      while (!signal.aborted) {
+        while (connection.items.length > 0) {
+          const item = connection.items.shift() as EventStreamItem
+          if (item.kind === 'end') return
+          if (item.kind === 'fail') throw item.error
+          yield item.value
+        }
+        if (signal.aborted) return
+        await new Promise<void>((resolve) => { connection.wake = resolve })
+        connection.wake = undefined
+      }
+    } finally {
+      signal.removeEventListener('abort', abort)
+      this.connections.delete(connection)
+    }
+  }
+}
+
+async function eventBench(
+  call: ConnectionHandle['rpc']['call'] = vi.fn<ConnectionHandle['rpc']['call']>()
+    .mockResolvedValue({ ok: true, value: undefined }),
+): Promise<{
+  readonly ctx: Context
+  readonly client: Fiber
+  readonly carrier: RemoteEventCarrier
+  readonly generation: GenerationHarness
+  readonly run: GenerationRun
+  readonly call: ConnectionHandle['rpc']['call']
+}> {
+  const carrier = new RemoteEventCarrier()
+  const { ctx, client, generation } = await benchFiber(
+    call,
+    'in-process',
+    carrier.open,
+  )
+  const run = generation.start()
+  await run.ready
+  return { ctx, client, carrier, generation, run, call }
+}
+
+function approvalFrame(eventId: string, agentId: string, prompt: string): object {
+  return {
+    type: 'waterfall',
+    event: 'fixture/approval',
+    eventId,
+    agentId,
+    request: { prompt },
+  }
+}
+
+describe('Client Remote transport readiness', () => {
+  it('creates logical stream supervisors against the installed Connection', async () => {
+    const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
+    const stream = ctx.remote.$stream({
+      name: 'fixture stream',
+      open: () => unexpectedInProcessStream(),
+      ended: () => new Error('fixture stream ended'),
+    })
+
+    expect(stream).toBeInstanceOf(RemoteStream)
+    await stream.dispose()
+    await client.dispose()
+  })
+
+  it('starts after Loader settlement and stops the owned loop on disposal', async () => {
+    const readiness = deferredReadiness()
+    const { client, start, stop } = await loaderReadinessBench(readiness.promise)
+    expect(start).not.toHaveBeenCalled()
+
+    readiness.resolve()
+    await vi.waitFor(() => { expect(start).toHaveBeenCalledTimes(1) })
+
+    await client.dispose()
+    expect(stop).toHaveBeenCalledTimes(1)
+  })
+
+  it('does not start when disposal wins the Loader-settlement race', async () => {
+    const readiness = deferredReadiness()
+    const { client, start, stop } = await loaderReadinessBench(readiness.promise)
+
+    await client.dispose()
+    readiness.resolve()
+    await Promise.resolve()
+
+    expect(start).not.toHaveBeenCalled()
+    expect(stop).not.toHaveBeenCalled()
+  })
+
+  it('leaves the transport stopped when Loader settlement rejects', async () => {
+    const readiness = deferredReadiness()
+    const { client, start, stop } = await loaderReadinessBench(readiness.promise)
+
+    readiness.reject(new Error('fixture Loader failed'))
+    await Promise.resolve()
+    await Promise.resolve()
+
+    expect(start).not.toHaveBeenCalled()
+    await client.dispose()
+    expect(stop).not.toHaveBeenCalled()
+  })
+})
+
 describe('Client Typert API', () => {
 describe('Client Typert API', () => {
   it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
   it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
@@ -271,6 +686,7 @@ describe('Client Typert API', () => {
     const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
     const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
     ctx.typert.contexts.registerClient('fixture', {
     ctx.typert.contexts.registerClient('fixture', {
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
+      resolve: id => id === 'agent-2' ? agentCtx : undefined,
     })
     })
     const assembly = ctx.plugin(Object.assign(
     const assembly = ctx.plugin(Object.assign(
       (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
       (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
@@ -301,6 +717,7 @@ describe('Client Typert API', () => {
     const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
     const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
     ctx.typert.contexts.registerClient('fixture', {
     ctx.typert.contexts.registerClient('fixture', {
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
+      resolve: id => id === 'agent-2' ? agentCtx : undefined,
     })
     })
     const assembly = ctx.plugin(Object.assign(
     const assembly = ctx.plugin(Object.assign(
       (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }),
       (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }),
@@ -346,6 +763,7 @@ describe('Client Typert API', () => {
     const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
     const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
     ctx.typert.contexts.registerClient('fixture', {
     ctx.typert.contexts.registerClient('fixture', {
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
+      resolve: id => id === 'agent-remounted' ? agentCtx : undefined,
     })
     })
     const direct = directDescriptor()
     const direct = directDescriptor()
     const context = contextDescriptor()
     const context = contextDescriptor()
@@ -432,6 +850,42 @@ describe('Client Typert API', () => {
     await retry()
     await retry()
   })
   })
 
 
+  it('rolls back earlier namespaces when a later namespace fails to install', async () => {
+    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+    const { scope: _scope, ...first } = directDescriptor()
+    const second: InvocationDescriptor = {
+      ...first,
+      id: '@fixture/archive#archive/store',
+      namespace: 'archive',
+      method: 'store',
+    }
+    const defineProperty = Object.defineProperty
+    const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
+      if (key === 'store') throw new Error('fixture later-namespace failure')
+      return defineProperty(target, key, attributes)
+    })
+    try {
+      await expect(ctx.remote.$mount({
+        package: '@fixture/failing-namespaces',
+        descriptors: [first, second],
+      })).rejects.toThrow('fixture later-namespace failure')
+    } finally {
+      spy.mockRestore()
+    }
+
+    expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
+    expect((ctx.remote as unknown as Record<string, unknown>).archive).toBeUndefined()
+    await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
+
+    const retry = await ctx.remote.$mount({
+      package: '@fixture/retry-namespaces',
+      descriptors: [first, second],
+    })
+    expect(ctx.remote.probe.create).toBeTypeOf('function')
+    expect((ctx.remote as unknown as Record<string, Record<string, unknown>>).archive?.store).toBeTypeOf('function')
+    await retry()
+  })
+
   it('rolls back a direct projection when its scoped projection fails to install', async () => {
   it('rolls back a direct projection when its scoped projection fails to install', async () => {
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     const disposeContext = await ctx.remote.$mount({
     const disposeContext = await ctx.remote.$mount({
@@ -579,7 +1033,7 @@ describe('Client Typert API', () => {
     })).rejects.toThrow('scope must select its only lookup parameter')
     })).rejects.toThrow('scope must select its only lookup parameter')
   })
   })
 
 
-  it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
+  it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
     const ctx = await bench(call)
     const ctx = await bench(call)
@@ -599,7 +1053,7 @@ describe('Client Typert API', () => {
     await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
     await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
       .rejects.toThrow('expected 2 business argument(s)')
       .rejects.toThrow('expected 2 business argument(s)')
     await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
     await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
-      .rejects.toThrow('no Client Context binder')
+      .rejects.toThrow('no Client Context adapter')
 
 
     ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
     ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
     await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
     await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
@@ -640,6 +1094,28 @@ describe('Client Typert API', () => {
     expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
     expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
   })
   })
 
 
+  it('keeps a namespace while another contribution still owns a method', async () => {
+    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+    const disposeCreate = await ctx.remote.$mount({
+      package: '@fixture/create-contribution',
+      descriptors: [directDescriptor()],
+    })
+    const disposeMaybe = await ctx.remote.$mount({
+      package: '@fixture/maybe-contribution',
+      descriptors: [maybeDescriptor()],
+    })
+    const namespace = ctx.get('remote.probe') as unknown as Record<string, unknown>
+
+    await disposeCreate()
+
+    expect(ctx.get('remote.probe') !== undefined).toBe(true)
+    expect(namespace.create).toBeUndefined()
+    expect(namespace.maybe).toBeTypeOf('function')
+
+    await disposeMaybe()
+    expect(ctx.get('remote.probe')).toBeUndefined()
+  })
+
   it('fails a method obtained from a withdrawn namespace getter', async () => {
   it('fails a method obtained from a withdrawn namespace getter', async () => {
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
     const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
@@ -804,7 +1280,7 @@ describe('Client Typert API', () => {
   })
   })
 
 
   it('owns each $on subscription in the calling fiber', async () => {
   it('owns each $on subscription in the calling fiber', async () => {
-    const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
+    const { ctx, client, carrier } = await eventBench()
     const seen: string[] = []
     const seen: string[] = []
     const subscriber = ctx.plugin(Object.assign(
     const subscriber = ctx.plugin(Object.assign(
       (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
       (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
@@ -812,103 +1288,1098 @@ describe('Client Typert API', () => {
     ))
     ))
     await subscriber
     await subscriber
 
 
-    ctx.remote.$dispatch('fixture/changed', ['settings'])
-    expect(seen).toEqual(['settings'])
+    expect(carrier.calls).toEqual([expect.objectContaining({
+      channel: '/api', endpoint: '$events', payload: { args: {} },
+    })])
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['settings'] })
+    await vi.waitFor(() => { expect(seen).toEqual(['settings']) })
 
 
     await subscriber.dispose()
     await subscriber.dispose()
-    ctx.remote.$dispatch('fixture/changed', ['after fiber disposal'])
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['after fiber disposal'] })
+    await Promise.resolve()
     expect(seen).toEqual(['settings'])
     expect(seen).toEqual(['settings'])
 
 
     await client.dispose()
     await client.dispose()
     expect(ctx.get('remote')).toBeUndefined()
     expect(ctx.get('remote')).toBeUndefined()
   })
   })
 
 
-  it('isolates a throwing listener from the rest of the same event', async () => {
-    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+  it('isolates throwing and rejected notification listeners', async () => {
+    const { ctx, client, carrier } = await eventBench()
     const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
     const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
     const seen: string[] = []
     const seen: string[] = []
-    const disposeFirst = ctx.remote.$on('fixture/changed', () => {
-      throw new Error('fixture listener failure')
-    })
+    const failingListener = (namespace: string): unknown => {
+      if (namespace === 'sync') throw new Error('fixture listener failure')
+      return Promise.reject(new Error('fixture async failure'))
+    }
+    const disposeThrowing = ctx.remote.$on('fixture/changed', failingListener)
     ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
     ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
     try {
     try {
-      ctx.remote.$dispatch('fixture/changed', ['credentials'])
-
-      expect(seen).toEqual(['credentials'])
-      expect(consoleError).toHaveBeenCalledWith(
-        'client api: Remote event "fixture/changed" listener threw:',
-        expect.any(Error),
-      )
-      disposeFirst()
-      ctx.remote.$dispatch('fixture/changed', ['commands'])
-      expect(seen).toEqual(['credentials', 'commands'])
-      expect(consoleError).toHaveBeenCalledTimes(1)
+      carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['sync'] })
+      await vi.waitFor(() => { expect(seen).toEqual(['sync']) })
+      carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['async'] })
+      await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async']) })
+      expect(consoleError).toHaveBeenCalledTimes(2)
+
+      disposeThrowing()
+      carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] })
+      await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async', 'survivor']) })
     } finally {
     } finally {
       consoleError.mockRestore()
       consoleError.mockRestore()
+      await client.dispose()
     }
     }
   })
   })
 
 
-  it('contains an async listener whose promise rejects', async () => {
-    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
-    const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+  it('retires only its own registration when one listener subscribes twice', async () => {
+    const { ctx, client, carrier } = await eventBench()
+    const seen: string[] = []
+    const listener = (namespace: string): void => { seen.push(namespace) }
+    const disposeFirst = ctx.remote.$on('fixture/changed', listener)
+    ctx.remote.$on('fixture/changed', listener)
+
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['both'] })
+    await vi.waitFor(() => { expect(seen).toEqual(['both', 'both']) })
+
+    disposeFirst()
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] })
+    await vi.waitFor(() => { expect(seen).toEqual(['both', 'both', 'survivor']) })
+
+    disposeFirst()
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['still here'] })
+    await vi.waitFor(() => {
+      expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
+    })
+    await client.dispose()
+  })
+
+  it('keeps the carrier handoff private', () => {
+    expectTypeOf<ClientRemote>().toHaveProperty('$on')
+    expectTypeOf<'$dispatch' extends keyof ClientRemote ? true : false>().toEqualTypeOf<false>()
+  })
+
+  it('drops an unobserved notification and accepts a null-prototype frame', async () => {
+    const { ctx, client, carrier } = await eventBench()
     const seen: string[] = []
     const seen: string[] = []
-    // The declared return is void, so nobody awaits an async listener: the
-    // rejection has to be contained here or it escapes as an unhandled one.
-    ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises
     ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
     ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
-    try {
-      ctx.remote.$dispatch('fixture/changed', ['credentials'])
-      await Promise.resolve()
-      await Promise.resolve()
 
 
-      expect(seen).toEqual(['credentials'])
+    carrier.emit({ type: 'emit', event: 'fixture/idle', args: [1] })
+    carrier.emit(Object.assign(Object.create(null) as Record<string, unknown>, {
+      type: 'emit',
+      event: 'fixture/changed',
+      args: ['null prototype'],
+    }))
+
+    await vi.waitFor(() => { expect(seen).toEqual(['null prototype']) })
+    await client.dispose()
+  })
+
+  it('delegates immediately when the Agent adapter or Context is unavailable', async () => {
+    const { ctx, client, carrier, call } = await eventBench()
+    carrier.emit(approvalFrame('event-no-adapter', 'agent-late', 'no adapter'))
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
+
+    const target = ctx.extend()
+    const resolve = vi.fn((id: unknown) => id === 'agent-found' ? target : undefined)
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-found') : undefined,
+      resolve,
+    })
+    carrier.emit(approvalFrame('event-missing-context', 'agent-missing', 'missing'))
+    carrier.emit(approvalFrame('event-no-listener', 'agent-found', 'delegate'))
+
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(3) })
+    expect(resolve).toHaveBeenCalledTimes(2)
+    for (const eventId of ['event-no-adapter', 'event-missing-context', 'event-no-listener']) {
+      expect(call).toHaveBeenCalledWith(
+        '/api',
+        '$events/result',
+        { args: { clientId: 'event-client-1', eventId, outcome: { kind: 'next' } } },
+        expect.any(AbortSignal),
+      )
+    }
+    await client.dispose()
+  })
+
+  it('reports Agent Context resolution failures and delegates', async () => {
+    const { ctx, client, carrier, call } = await eventBench()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: () => undefined,
+      resolve: () => { throw new Error('fixture Context lookup failed') },
+    })
+    const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      carrier.emit(approvalFrame('event-resolve-error', 'agent-error', 'resolve'))
+      await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
       expect(consoleError).toHaveBeenCalledWith(
       expect(consoleError).toHaveBeenCalledWith(
-        'client api: Remote event "fixture/changed" listener threw:',
-        expect.any(Error),
+        'client api: Remote event "fixture/approval" listener threw:',
+        expect.objectContaining({ message: 'fixture Context lookup failed' }),
+      )
+      expect(call).toHaveBeenCalledWith(
+        '/api',
+        '$events/result',
+        {
+          args: {
+            clientId: 'event-client-1',
+            eventId: 'event-resolve-error',
+            outcome: { kind: 'next' },
+          },
+        },
+        expect.any(AbortSignal),
       )
       )
     } finally {
     } finally {
       consoleError.mockRestore()
       consoleError.mockRestore()
+      await client.dispose()
     }
     }
   })
   })
 
 
-  it('retires only its own registration when one listener subscribes twice', async () => {
-    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+  it('normalizes undefined waterfall results and rejects non-JSON results', async () => {
+    const { ctx, client, carrier, call } = await eventBench()
+    const target = ctx.extend()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-results') : undefined,
+      resolve: id => id === 'agent-results' ? target : undefined,
+    })
+    target.remote.$on('fixture/approval', async request => request.prompt === 'undefined'
+      ? undefined as unknown as FixtureApprovalOutcome
+      : Symbol('not JSON') as unknown as FixtureApprovalOutcome)
+
+    carrier.emit(approvalFrame('event-undefined', 'agent-results', 'undefined'))
+    carrier.emit(approvalFrame('event-invalid', 'agent-results', 'invalid'))
+
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(2) })
+    expect(call).toHaveBeenCalledWith(
+      '/api',
+      '$events/result',
+      { args: { clientId: 'event-client-1', eventId: 'event-undefined', outcome: { kind: 'result' } } },
+      expect.any(AbortSignal),
+    )
+    expect(call).toHaveBeenCalledWith(
+      '/api',
+      '$events/result',
+      {
+        args: {
+          clientId: 'event-client-1',
+          eventId: 'event-invalid',
+          outcome: {
+            kind: 'rejected',
+            error: {
+              name: 'TypeError',
+              message: 'Remote event listener result is not lossless JSON data',
+            },
+          },
+        },
+      },
+      expect.any(AbortSignal),
+    )
+    await client.dispose()
+  })
+
+  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: {} },
+    })
+    const { client, carrier, run } = await eventBench(call)
+
+    carrier.emit(approvalFrame('event-result-rejected', 'agent-missing', 'respond'))
+
+    await expect(run.done).rejects.toThrow('fixture result rejected')
+    await client.dispose()
+  })
+
+  it('filters scoped waterfall listeners and returns the first claimed result', async () => {
+    const call = vi.fn<ConnectionHandle['rpc']['call']>()
+      .mockResolvedValue({ ok: true, value: undefined })
+    const { ctx, client, carrier } = await eventBench(call)
+    const target = ctx.extend({
+      [Context.filter](candidate: Context): boolean {
+        const tag = (candidate as Context & { [fixtureContextTag]?: string })[fixtureContextTag]
+        return tag === undefined || tag === 'agent-1'
+      },
+    })
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-1') : undefined,
+      resolve: id => id === 'agent-1' ? target : undefined,
+    })
+    const matching = ctx.extend({ [fixtureContextTag]: 'agent-1' })
+    const excluded = ctx.extend({ [fixtureContextTag]: 'agent-2' })
     const seen: string[] = []
     const seen: string[] = []
-    // One function object, two registrations. A table keyed by listener identity
-    // stores it once, so the first frame would reach it once instead of twice
-    // and either disposer would silence both.
-    const listener = (namespace: string): void => { seen.push(namespace) }
-    const disposeFirst = ctx.remote.$on('fixture/changed', listener)
-    ctx.remote.$on('fixture/changed', listener)
+    ctx.remote.$on('fixture/approval', async function (request, next) {
+      expect(this).toBe(target)
+      expect(request.agent).toBe(target)
+      expect(request.signal).toBeInstanceOf(AbortSignal)
+      seen.push('root')
+      return next()
+    })
+    matching.remote.$on('fixture/approval', async (_request, next) => {
+      seen.push('matching-next')
+      return next()
+    })
+    excluded.remote.$on('fixture/approval', async () => {
+      seen.push('excluded')
+      return 'unavailable'
+    })
+    matching.remote.$on('fixture/approval', async () => {
+      seen.push('matching-result')
+      return 'allowed'
+    })
 
 
-    ctx.remote.$dispatch('fixture/changed', ['both'])
-    expect(seen).toEqual(['both', 'both'])
+    carrier.emit(approvalFrame('event-1', 'agent-1', 'ship'))
 
 
-    // The surviving registration keeps receiving after its twin retires.
-    disposeFirst()
-    ctx.remote.$dispatch('fixture/changed', ['survivor'])
-    expect(seen).toEqual(['both', 'both', 'survivor'])
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
+    expect(seen).toEqual(['root', 'matching-next', 'matching-result'])
+    expect(call).toHaveBeenCalledWith(
+      '/api',
+      '$events/result',
+      {
+        args: {
+          clientId: 'event-client-1',
+          eventId: 'event-1',
+          outcome: { kind: 'result', value: 'allowed' },
+        },
+      },
+      expect.any(AbortSignal),
+    )
+    await client.dispose()
+  })
 
 
-    // Disposing twice is inert: the record is already gone, so the second call
-    // must not splice the surviving twin out from under its own owner.
-    disposeFirst()
-    ctx.remote.$dispatch('fixture/changed', ['still here'])
-    expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
+  it('returns a scoped listener rejection to the Host', async () => {
+    const call = vi.fn<ConnectionHandle['rpc']['call']>()
+      .mockResolvedValue({ ok: true, value: undefined })
+    const { ctx, client, carrier } = await eventBench(call)
+    const target = ctx.extend()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-rejected') : undefined,
+      resolve: id => id === 'agent-rejected' ? target : undefined,
+    })
+    const rejection = Object.assign(new Error('the user cancelled ask_user_question'), {
+      name: 'UserQuestionError',
+      code: 'ASK_CANCELLED',
+      details: { questionId: 'question-1' },
+    })
+    target.remote.$on('fixture/approval', () => Promise.reject(rejection))
+
+    carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled'))
+
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
+    expect(call).toHaveBeenCalledWith(
+      '/api',
+      '$events/result',
+      {
+        args: {
+          clientId: 'event-client-1',
+          eventId: 'event-rejected',
+          outcome: {
+            kind: 'rejected',
+            error: {
+              name: 'UserQuestionError',
+              message: 'the user cancelled ask_user_question',
+              code: 'ASK_CANCELLED',
+              details: { questionId: 'question-1' },
+            },
+          },
+        },
+      },
+      expect.any(AbortSignal),
+    )
+    await client.dispose()
   })
   })
 
 
-  it('separates the consumer verb from the carrier handoff', () => {
-    expectTypeOf<ClientRemote>().toHaveProperty('$on')
-    // The carrier owning the frame sink calls this; a consumer subscribes instead.
-    expectTypeOf<ClientRemote>().toHaveProperty('$dispatch')
+  it('returns Context-filter failures as rejections', async () => {
+    const call = vi.fn<ConnectionHandle['rpc']['call']>()
+      .mockResolvedValue({ ok: true, value: undefined })
+    const { ctx, client, carrier } = await eventBench(call)
+    const target = ctx.extend({
+      [Context.filter](): boolean {
+        throw new Error('fixture Context filter failed')
+      },
+    })
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-filter-failure') : undefined,
+      resolve: id => id === 'agent-filter-failure' ? target : undefined,
+    })
+    ctx.remote.$on('fixture/approval', async (_request, next) => next())
+
+    carrier.emit(approvalFrame('event-filter-failure', 'agent-filter-failure', 'filter'))
+
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
+    expect(call).toHaveBeenCalledWith(
+      '/api',
+      '$events/result',
+      {
+        args: {
+          clientId: 'event-client-1',
+          eventId: 'event-filter-failure',
+          outcome: {
+            kind: 'rejected',
+            error: {
+              name: 'Error',
+              message: 'fixture Context filter failed',
+            },
+          },
+        },
+      },
+      expect.any(AbortSignal),
+    )
+    await client.dispose()
   })
   })
 
 
-  it('drops a forwarded event nobody subscribes to', async () => {
-    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+  it('cancels a pending Client listener without returning a late result', async () => {
+    const { ctx, client, carrier, call } = await eventBench()
+    const target = ctx.extend()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-cancel') : undefined,
+      resolve: id => id === 'agent-cancel' ? target : undefined,
+    })
+    const entered = Promise.withResolvers<AbortSignal>()
+    target.remote.$on('fixture/approval', async (request) => {
+      const signal = request.signal as AbortSignal
+      entered.resolve(signal)
+      await new Promise<void>((resolve) => {
+        if (signal.aborted) resolve()
+        else signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+      return 'allowed'
+    })
+    carrier.emit(approvalFrame('event-cancel', 'agent-cancel', 'wait'))
+    const deliverySignal = await entered.promise
+
+    carrier.emit({ type: 'cancel', eventId: 'event-cancel' })
+    await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) })
+    await Promise.resolve()
+    expect(call).not.toHaveBeenCalled()
+
+    await client.dispose()
+  })
+
+  it('drops a settled listener result when cancellation wins before reply', async () => {
+    const { ctx, client, carrier, call } = await eventBench()
+    const target = ctx.extend()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-cancel-race') : undefined,
+      resolve: id => id === 'agent-cancel-race' ? target : undefined,
+    })
+    const entered = Promise.withResolvers<AbortSignal>()
+    const release = Promise.withResolvers<undefined>()
+    target.remote.$on('fixture/approval', async (request) => {
+      entered.resolve(request.signal as AbortSignal)
+      await release.promise
+      return 'allowed'
+    })
+    carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait'))
+    const deliverySignal = await entered.promise
+
+    release.resolve(undefined)
+    carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' })
+    await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) })
+    await Promise.resolve()
+    expect(call).not.toHaveBeenCalled()
+
+    await client.dispose()
+  })
+
+  it('cancels pending listener work when the generation ends', async () => {
+    const { ctx, client, carrier, run, call } = await eventBench()
+    const target = ctx.extend()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('agent-generation') : undefined,
+      resolve: id => id === 'agent-generation' ? target : undefined,
+    })
+    const entered = Promise.withResolvers<AbortSignal>()
+    target.remote.$on('fixture/approval', async (request) => {
+      const signal = request.signal as AbortSignal
+      entered.resolve(signal)
+      await new Promise<void>((resolve) => {
+        if (signal.aborted) resolve()
+        else signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+      return 'allowed'
+    })
+    carrier.emit(approvalFrame('event-generation', 'agent-generation', 'wait'))
+    const deliverySignal = await entered.promise
+
+    run.abort(new Error('fixture generation ended'))
+    await expect(run.done).resolves.toBeUndefined()
+    expect(deliverySignal.aborted).toBe(true)
+    expect(call).not.toHaveBeenCalled()
+
+    await client.dispose()
+  })
+
+  it('contains a result transport failure after the generation is cancelled', async () => {
+    const response = Promise.withResolvers<never>()
+    const call = vi.fn<ConnectionHandle['rpc']['call']>(() => response.promise)
+    const { client, carrier, run } = await eventBench(call)
+    carrier.emit(approvalFrame('event-late-result', 'agent-missing', 'respond'))
+    await vi.waitFor(() => { expect(call).toHaveBeenCalledOnce() })
+
+    run.abort(new Error('fixture generation cancelled'))
+    response.reject(new Error('fixture late result failure'))
+    await expect(run.done).resolves.toBeUndefined()
+
+    await client.dispose()
+  })
+
+  it('normalizes a non-Error result transport failure', async () => {
+    const call = vi.fn<ConnectionHandle['rpc']['call']>().mockRejectedValue('fixture transport failure')
+    const { client, carrier, run } = await eventBench(call)
+
+    carrier.emit(approvalFrame('event-result-throw', 'agent-missing', 'respond'))
+
+    await expect(run.done).rejects.toMatchObject({
+      message: 'client api: Remote event result delivery failed',
+      cause: 'fixture transport failure',
+    })
+    await client.dispose()
+  })
+
+  it('keeps the newer generation tracked when an overlapping generation settles', async () => {
+    const { client, generation, run } = await eventBench()
+    const overlapping = generation.startOverlapping()
+    await overlapping.ready
+
+    run.abort(new Error('fixture older generation ended'))
+    await expect(run.done).resolves.toBeUndefined()
+    overlapping.abort(new Error('fixture newer generation ended'))
+    await expect(overlapping.done).resolves.toBeUndefined()
+    await client.dispose()
+  })
+
+  it('opens the forwarded-event stream on the browser Remote mux', async () => {
+    await withFakeWebSocket('https://harness.example', async () => {
+      const call = vi.fn<ConnectionHandle['rpc']['call']>()
+        .mockResolvedValue({ ok: true, value: undefined })
+      const { ctx, client, generation } = await benchFiber(call, 'web')
+      const seen: string[] = []
+      const target = ctx.extend()
+      ctx.typert.contexts.registerClient('agent', {
+        identity: candidate => candidate === target ? agentId('agent-browser') : undefined,
+        resolve: id => id === 'agent-browser' ? target : undefined,
+      })
+      ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
+      target.remote.$on('fixture/approval', async function (request) {
+        expect(this).toBe(target)
+        expect(request.agent).toBe(this)
+        expect(request.signal).toBeInstanceOf(AbortSignal)
+        return 'allowed'
+      })
+      const run = generation.start()
+
+      await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
+      const socket = FakeWebSocket.sockets[0]!
+      const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
+      expect(opened).toMatchObject({
+        type: 'open', endpoint: '$events', payload: { args: {} },
+      })
+      socket.receive({
+        type: 'item',
+        streamId: opened.streamId,
+        value: { type: 'ready', clientId: 'browser-client' },
+      })
+      await run.ready
+      socket.receive({
+        type: 'item',
+        streamId: opened.streamId,
+        value: { type: 'emit', event: 'fixture/changed', args: ['browser'] },
+      })
+      await vi.waitFor(() => { expect(seen).toEqual(['browser']) })
+
+      socket.receive({
+        type: 'item',
+        streamId: opened.streamId,
+        value: {
+          type: 'waterfall',
+          event: 'fixture/approval',
+          eventId: 'event-browser',
+          agentId: 'agent-browser',
+          request: { prompt: 'browser approval' },
+        },
+      })
+      await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
+      expect(socket.sent).toHaveLength(1)
+      expect(call).toHaveBeenCalledWith(
+        '/api',
+        '$events/result',
+        {
+          args: {
+            clientId: 'browser-client',
+            eventId: 'event-browser',
+            outcome: { kind: 'result', value: 'allowed' },
+          },
+        },
+        expect.any(AbortSignal),
+      )
+
+      await client.dispose()
+    })
+  })
+
+  it('publishes the Fixture Host description after Remote events report ready', async () => {
+    const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
+    Object.defineProperty(globalThis, 'location', {
+      configurable: true,
+      value: { hostname: '127.0.0.1', search: '?fixture' },
+    })
+    const ctx = new Context()
+    try {
+      await ctx.plugin(TypertRegistry)
+      await ctx.plugin({ inject: [], apply: applyConnection })
+      await ctx.plugin({ inject, apply })
+      const connection = ctx.get('connection') as ConnectionHandle | undefined
+      if (connection === undefined) throw new Error('fixture Connection service is unavailable')
+
+      await vi.waitFor(() => {
+        expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture')
+      })
+    } finally {
+      await ctx.fiber.dispose()
+      if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
+      else Object.defineProperty(globalThis, 'location', locationDescriptor)
+    }
+  })
+
+  it.each([
+    null,
+    [],
+    {},
+    { type: 'pending' },
+    { type: 'ready' },
+    { type: 'ready', clientId: '' },
+    { type: 'ready', clientId: 'client', extra: true },
+    { type: 'emit', event: 'fixture/changed', args: ['too early'] },
+  ])('rejects malformed forwarded-event readiness item %#', async (opening) => {
+    const open: NonNullable<ConnectionHandle['rpc']['open']> = () => (async function *() {
+      yield opening
+    })()
+    const { client, generation } = await benchFiber(
+      vi.fn<ConnectionHandle['rpc']['call']>(),
+      'in-process',
+      open,
+    )
+    const run = generation.start()
+    try {
+      await expect(run.done).rejects.toThrow('forwarded Remote event stream did not begin with ready')
+    } finally {
+      await client.dispose()
+    }
+  })
+
+  it('propagates physical carrier failure and opens events for the replacement generation', async () => {
+    const { ctx, client, carrier, generation, run } = await eventBench()
     const seen: string[] = []
     const seen: string[] = []
     ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
     ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
+    expect(carrier.calls).toHaveLength(1)
+
+    carrier.fail(new RemoteStreamCarrierError('fixture generation lost'))
+    await expect(run.done).rejects.toThrow('fixture generation lost')
+    const replacement = generation.start()
+    await replacement.ready
+    await vi.waitFor(() => { expect(carrier.calls).toHaveLength(2) })
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['replacement'] })
+    await vi.waitFor(() => { expect(seen).toEqual(['replacement']) })
 
 
-    ctx.remote.$dispatch('fixture/idle', [1])
+    await client.dispose()
+  })
 
 
+  it.each([
+    {
+      name: 'Host failure',
+      stop: (carrier: RemoteEventCarrier) => {
+        carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {}))
+      },
+      message: 'fixture Host failed',
+    },
+    {
+      name: 'normal end',
+      stop: (carrier: RemoteEventCarrier) => { carrier.end() },
+      message: 'forwarded Remote event stream ended unexpectedly',
+    },
+  ])('fails the active generation after $name', async ({ stop, message }) => {
+    const { ctx, client, carrier, run } = await eventBench()
+    const seen: string[] = []
+    ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
+    stop(carrier)
+    await expect(run.done).rejects.toThrow(message)
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] })
+    await Promise.resolve()
+    expect(carrier.calls).toHaveLength(1)
     expect(seen).toEqual([])
     expect(seen).toEqual([])
+    await client.dispose()
+  })
+
+  it.each([
+    'not an object',
+    null,
+    [],
+    {},
+    { type: 'unknown' },
+    { type: 'emit', event: 'fixture/changed' },
+    { type: 'emit', event: 'fixture/changed', args: [], extra: true },
+    { type: 'emit', event: 1, args: [] },
+    { type: 'emit', event: '', args: [] },
+    { type: 'emit', event: 'fixture/changed', args: {} },
+    { type: 'emit', event: 'fixture/changed', args: [1n] },
+    { type: 'waterfall', event: 'fixture/approval', eventId: '', agentId: 'agent-1', request: {} },
+    { type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: '', request: {} },
+    {
+      type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { agent: null },
+    },
+    {
+      type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { signal: null },
+    },
+    { type: 'cancel', eventId: '' },
+    { type: 'cancel', eventId: 'event-1', extra: true },
+  ])('rejects malformed forwarded-event frame %# and stops that stream', async (frame) => {
+    const { ctx, client, carrier, run } = await eventBench()
+    const seen: string[] = []
+    ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
+    carrier.emit(frame)
+    await expect(run.done).rejects.toThrow('client api: invalid forwarded Remote event frame')
+    carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] })
+    await Promise.resolve()
+    expect(carrier.calls).toHaveLength(1)
+    expect(seen).toEqual([])
+    await client.dispose()
+  })
+
+  it('aborts and awaits forwarded-event delivery during disposal', async () => {
+    const { ctx, client, carrier, run } = await eventBench()
+    ctx.remote.$on('fixture/changed', () => {})
+    expect(carrier.activeConnections).toBe(1)
+    const signal = carrier.calls[0]?.signal
+
+    await client.dispose()
+    await expect(run.done).resolves.toBeUndefined()
+
+    expect(signal?.aborted).toBe(true)
+    expect(carrier.activeConnections).toBe(0)
+    expect(ctx.get('remote')).toBeUndefined()
+  })
+
+  it('rejects a generation when its Connection has been withdrawn', async () => {
+    const carrier = new RemoteEventCarrier()
+    const { ctx, client, generation } = await benchFiber(
+      vi.fn<ConnectionHandle['rpc']['call']>(),
+      'in-process',
+      carrier.open,
+    )
+    ctx.set('connection', undefined)
+    const run = generation.start()
+    await expect(run.done).rejects.toThrow('$events has no active Connection')
+    expect(carrier.calls).toEqual([])
+    await client.dispose()
+  })
+
+  it('guards stream iteration across mount and Connection withdrawal', async () => {
+    const call = vi.fn<ConnectionHandle['rpc']['call']>()
+    const ctx = await bench(call)
+    const firstDispose = await ctx.remote.$mount({
+      package: '@fixture/stream-first', descriptors: [streamDescriptor()],
+    })
+    const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]()
+    await firstDispose()
+    await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted')
+
+    const secondDispose = await ctx.remote.$mount({
+      package: '@fixture/stream-second', descriptors: [streamDescriptor()],
+    })
+    ctx.set('connection', undefined)
+    await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next())
+      .rejects.toThrow('probe/watch has no active Connection')
+
+    let release!: () => void
+    const released = new Promise<void>((resolve) => { release = resolve })
+    let markStarted!: () => void
+    const started = new Promise<void>((resolve) => { markStarted = resolve })
+    const source = async function *(): AsyncIterable<string> {
+      markStarted()
+      await released
+      yield 'late item'
+    }
+    ctx.set('connection', {
+      rpc: { call, open: () => source() },
+    } as unknown as ConnectionHandle)
+    const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]()
+    const pending = active.next()
+    await started
+    await secondDispose()
+    release()
+    await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted')
+  })
+
+  it('publishes a namespace only after every contributed method is installed', async () => {
+    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+    let visible: string[] | undefined
+    const consumer = ctx.plugin({
+      inject: ['remote.probe'],
+      apply(scope) {
+        const namespace = scope.get('remote.probe') as unknown as Record<string, unknown>
+        visible = [typeof namespace.watch, typeof namespace.archive]
+      },
+    })
+    const archive: InvocationDescriptor = {
+      ...streamDescriptor(),
+      id: '@fixture/probe#probe/archive',
+      method: 'archive',
+    }
+
+    const dispose = await ctx.remote.$mount({
+      package: '@fixture/atomic-namespace',
+      descriptors: [streamDescriptor(), archive],
+    })
+    await consumer.await()
+
+    expect(visible).toEqual(['function', 'function'])
+    await dispose()
+  })
+
+  it('normalizes worker-local structural stream failures without sharing class identity', async () => {
+    const cases = [{
+      failure: Object.assign(new Error('fixture Host rejected the stream'), {
+        dshRemoteStreamFailure: {
+          kind: 'remote' as const,
+          code: 'fixture-rejected',
+          details: { retry: false },
+        },
+      }),
+      assert: (error: unknown) => {
+        expect(error).toBeInstanceOf(RemoteStreamError)
+        expect(error).toMatchObject({
+          code: 'fixture-rejected',
+          message: 'fixture Host rejected the stream',
+          details: { retry: false },
+        })
+      },
+    }, {
+      failure: Object.assign(new Error('worker carrier stopped'), {
+        dshRemoteStreamFailure: { kind: 'carrier' as const },
+      }),
+      assert: (error: unknown) => {
+        expect(error).toBeInstanceOf(RemoteStreamCarrierError)
+        expect(error).toMatchObject({ message: 'worker carrier stopped' })
+      },
+    }, {
+      failure: 'caller abort sentinel',
+      assert: (error: unknown) => { expect(error).toBe('caller abort sentinel') },
+    }]
+
+    for (const testCase of cases) {
+      const open: NonNullable<ConnectionHandle['rpc']['open']> = () => (async function *(): AsyncGenerator {
+        throw testCase.failure
+      })()
+      const { ctx, client } = await benchFiber(
+        vi.fn<ConnectionHandle['rpc']['call']>(),
+        'in-process',
+        open,
+      )
+      const dispose = await ctx.remote.$mount({ package: '@fixture/worker-stream', descriptors: [streamDescriptor()] })
+      try {
+        const error = await ctx.remote.probe.watch('failure')[Symbol.asyncIterator]().next()
+          .then(() => undefined, (reason: unknown) => reason)
+        testCase.assert(error)
+      } finally {
+        await dispose()
+        await client.dispose()
+      }
+    }
+  })
+
+  it('multiplexes Remote streams without using the Connection RPC caller', async () => {
+    const originalWebSocket = globalThis.WebSocket
+    const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
+    ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
+    Object.defineProperty(globalThis, 'location', {
+      configurable: true,
+      value: { origin: 'https://harness.example' },
+    })
+    FakeWebSocket.sockets.length = 0
+    const call = vi.fn<ConnectionHandle['rpc']['call']>()
+    const ctx = await bench(call, 'web')
+    expect(FakeWebSocket.sockets).toHaveLength(1)
+    const dispose = await ctx.remote.$mount({ package: '@fixture/stream', descriptors: [streamDescriptor()] })
+    try {
+      const first = ctx.remote.probe.watch('alpha')[Symbol.asyncIterator]()
+      const firstItem = first.next()
+      await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
+      const socket = FakeWebSocket.sockets[0]!
+      expect(socket.url).toBe('wss://harness.example/api/remote.mux')
+      const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
+      expect(opened).toMatchObject({
+        type: 'open',
+        endpoint: 'probe/watch',
+        payload: { args: { topic: 'alpha' } },
+      })
+      socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:one' })
+      await expect(firstItem).resolves.toEqual({ done: false, value: 'alpha:one' })
+      const firstEnd = first.next()
+      socket.receive({ type: 'end', streamId: opened.streamId })
+      await expect(firstEnd).resolves.toEqual({ done: true, value: undefined })
+
+      const failed = ctx.remote.probe.watch('failure')[Symbol.asyncIterator]()
+      const failedItem = failed.next()
+      await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
+      const failedOpen = JSON.parse(socket.sent[1]!) as { streamId: string }
+      socket.receive({
+        type: 'error',
+        streamId: failedOpen.streamId,
+        error: {
+          code: 'lookup-unavailable',
+          message: 'fixture stream failed',
+          details: { lookup: 'missing' },
+        },
+      })
+      await expect(failedItem).rejects.toMatchObject({
+        name: 'RemoteStreamError',
+        code: 'lookup-unavailable',
+        message: 'fixture stream failed',
+        details: { lookup: 'missing' },
+      })
+
+      const abort = new AbortController()
+      const cancelled = ctx.remote.probe.watch('cancel', abort.signal)[Symbol.asyncIterator]()
+      const cancelledItem = cancelled.next()
+      await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) })
+      const cancelledOpen = JSON.parse(socket.sent[2]!) as { streamId: string }
+      const cancellation = new Error('caller cancelled')
+      socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'already queued' })
+      abort.abort(cancellation)
+      socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'after cancellation' })
+      await expect(cancelledItem).rejects.toBe(cancellation)
+      await vi.waitFor(() => {
+        expect(socket.sent.map(text => JSON.parse(text) as unknown)).toContainEqual({
+          type: 'cancel', streamId: cancelledOpen.streamId,
+        })
+      })
+      expect(call).not.toHaveBeenCalled()
+    } finally {
+      await dispose()
+      await ctx.fiber.dispose()
+      FakeWebSocket.sockets.length = 0
+      FakeWebSocket.autoOpen = true
+      FakeWebSocket.dispatchClose = true
+      if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
+      else globalThis.WebSocket = originalWebSocket
+      if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
+      else Object.defineProperty(globalThis, 'location', locationDescriptor)
+    }
   })
   })
 })
 })
+
+describe('Remote stream client carrier lifecycle', () => {
+  it('connects without a logical stream, reconnects after failures, and stops permanently', async () => {
+    await withFakeWebSocket('https://harness.example', async () => {
+      FakeWebSocket.autoOpen = false
+      vi.useFakeTimers()
+      const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+      try {
+        const client = new RemoteStreamMuxClient()
+        client.start()
+        client.start()
+        expect(FakeWebSocket.sockets).toHaveLength(1)
+
+        const failed = FakeWebSocket.sockets[0]!
+        failed.fail()
+        await vi.advanceTimersByTimeAsync(500)
+        expect(FakeWebSocket.sockets).toHaveLength(2)
+
+        const connected = FakeWebSocket.sockets[1]!
+        connected.open()
+        await vi.advanceTimersByTimeAsync(0)
+        expect(connected.sent).toEqual([])
+        connected.fail()
+        await vi.advanceTimersByTimeAsync(500)
+        expect(FakeWebSocket.sockets).toHaveLength(3)
+
+        const replacement = FakeWebSocket.sockets[2]!
+        replacement.open()
+        replacement.drop()
+        await vi.advanceTimersByTimeAsync(500)
+        expect(FakeWebSocket.sockets).toHaveLength(4)
+
+        const final = FakeWebSocket.sockets[3]!
+        final.open()
+        await vi.advanceTimersByTimeAsync(0)
+        await client.close()
+        await client.close()
+        client.start()
+        await expect(client.open('feed/follow', {}, new AbortController().signal)
+          [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed')
+        await vi.advanceTimersByTimeAsync(20_000)
+
+        expect(FakeWebSocket.sockets).toHaveLength(4)
+        expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' })
+        expect(warn).toHaveBeenCalledTimes(3)
+
+        const stopping = new RemoteStreamMuxClient()
+        stopping.start()
+        const racing = FakeWebSocket.sockets[4]!
+        racing.open()
+        racing.drop()
+        await stopping.close()
+        await vi.advanceTimersByTimeAsync(20_000)
+        expect(FakeWebSocket.sockets).toHaveLength(5)
+      } finally {
+        warn.mockRestore()
+        vi.useRealTimers()
+      }
+    })
+  })
+
+  it('shares an in-flight connection and uses the internal ws URL without a browser origin', async () => {
+    await withFakeWebSocket(undefined, async () => {
+      FakeWebSocket.autoOpen = false
+      const client = new RemoteStreamMuxClient()
+      const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal)
+        [Symbol.asyncIterator]()
+      const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal)
+        [Symbol.asyncIterator]()
+      const firstPending = first.next()
+      const secondPending = second.next()
+      expect(FakeWebSocket.sockets).toHaveLength(1)
+      const socket = FakeWebSocket.sockets[0]!
+      expect(socket.url).toBe('ws://dsh.internal/api/remote.mux')
+
+      socket.open()
+      await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
+      const streamIds = socket.sent.map(text => (JSON.parse(text) as { streamId: string }).streamId)
+      socket.receive({ type: 'end', streamId: streamIds[0] })
+      socket.receive({ type: 'end', streamId: streamIds[1] })
+      await expect(firstPending).resolves.toEqual({ done: true, value: undefined })
+      await expect(secondPending).resolves.toEqual({ done: true, value: undefined })
+      await client.close()
+    })
+  })
+
+  it('keeps waiters across failed attempts and contains waiter cancellation', async () => {
+    await withFakeWebSocket('null', async () => {
+      FakeWebSocket.autoOpen = false
+      vi.useFakeTimers()
+      const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+      try {
+        const closedClient = new RemoteStreamMuxClient()
+        const closed = closedClient.open('feed/follow', {}, new AbortController().signal)
+          [Symbol.asyncIterator]().next()
+        FakeWebSocket.sockets[0]!.drop()
+        await vi.advanceTimersByTimeAsync(500)
+
+        const replacement = FakeWebSocket.sockets[1]!
+        replacement.open()
+        await vi.advanceTimersByTimeAsync(0)
+        const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string }
+        replacement.receive({ type: 'end', streamId })
+        await expect(closed).resolves.toEqual({ done: true, value: undefined })
+        await closedClient.close()
+
+        const disposedClient = new RemoteStreamMuxClient()
+        const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal)
+          [Symbol.asyncIterator]().next()
+        FakeWebSocket.sockets[2]!.fail()
+        await disposedClient.close()
+        await expect(disposed).rejects.toThrow('Remote stream client disposed')
+
+        const abortedClient = new RemoteStreamMuxClient()
+        const abort = new AbortController()
+        const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next()
+        abort.abort('cancelled while connecting')
+        await expect(aborted).rejects.toBe('cancelled while connecting')
+        await abortedClient.close()
+        expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux')
+      } finally {
+        warn.mockRestore()
+        vi.useRealTimers()
+      }
+    })
+  })
+
+  it('fails active streams on an invalid frame and ignores later frames', async () => {
+    await withFakeWebSocket('https://harness.example', async () => {
+      const client = new RemoteStreamMuxClient()
+      const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]()
+      const pending = stream.next()
+      await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
+      const socket = FakeWebSocket.sockets[0]!
+      const { streamId } = JSON.parse(socket.sent[0]!) as { streamId: string }
+      FakeWebSocket.dispatchClose = false
+      socket.receiveRaw(new Uint8Array([1, 2, 3]))
+      socket.receive({ type: 'item', streamId, value: 'too late' })
+      socket.drop()
+
+      await expect(pending).rejects.toMatchObject({
+        name: 'RemoteStreamCarrierError', message: 'api gateway: invalid Remote stream frame',
+      })
+      expect(socket.closedWith).toContainEqual({ code: 4002, reason: 'invalid Remote stream frame' })
+      await client.close()
+    })
+  })
+
+  it('completes a stream and drops a frame racing with cancellation', async () => {
+    await withFakeWebSocket('https://harness.example', async () => {
+      const client = new RemoteStreamMuxClient()
+      const completed = client.open('feed/follow', {}, new AbortController().signal)
+        [Symbol.asyncIterator]()
+      const completedPending = completed.next()
+      await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
+      const socket = FakeWebSocket.sockets[0]!
+      const completedOpen = JSON.parse(socket.sent[0]!) as { streamId: string }
+      socket.receive({ type: 'end', streamId: completedOpen.streamId })
+      await expect(completedPending).resolves.toEqual({ done: true, value: undefined })
+
+      const abort = new AbortController()
+      const cancelled = client.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next()
+      await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
+      const cancelledOpen = JSON.parse(socket.sent[1]!) as { streamId: string }
+      const reason = new Error('fixture cancellation race')
+      abort.abort(reason)
+      socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'too late' })
+      await expect(cancelled).rejects.toBe(reason)
+      await client.close()
+    })
+  })
+
+  it('contains non-Error cancellation reasons and late socket close events', async () => {
+    await withFakeWebSocket('http://harness.example', async () => {
+      const cancelledClient = new RemoteStreamMuxClient()
+      const abort = new AbortController()
+      const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next()
+      await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
+      abort.abort('caller cancelled')
+      await expect(cancelled).rejects.toThrow('caller cancelled')
+      await cancelledClient.close()
+
+      FakeWebSocket.dispatchClose = false
+      const disposedClient = new RemoteStreamMuxClient()
+      const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal)
+        [Symbol.asyncIterator]().next()
+      await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) })
+      const disposedSocket = FakeWebSocket.sockets[1]!
+      await disposedClient.close()
+      disposedSocket.receive({ type: 'end', streamId: 'stale' })
+      disposedSocket.drop()
+      await expect(disposed).rejects.toThrow('Remote stream client disposed')
+    })
+  })
+})
+
+async function withFakeWebSocket(
+  origin: string | undefined,
+  run: () => Promise<void>,
+): Promise<void> {
+  const originalWebSocket = globalThis.WebSocket
+  const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
+  ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
+  if (origin === undefined) Reflect.deleteProperty(globalThis, 'location')
+  else Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } })
+  FakeWebSocket.sockets.length = 0
+  FakeWebSocket.autoOpen = true
+  FakeWebSocket.dispatchClose = true
+  try {
+    await run()
+  } finally {
+    FakeWebSocket.sockets.length = 0
+    FakeWebSocket.autoOpen = true
+    FakeWebSocket.dispatchClose = true
+    if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
+    else globalThis.WebSocket = originalWebSocket
+    if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
+    else Object.defineProperty(globalThis, 'location', locationDescriptor)
+  }
+}

+ 62 - 0
packages/api/gateway/tests/gateway.host.spec.ts

@@ -1046,6 +1046,56 @@ describe('TypertGatewayService', () => {
     expect(connection.handler).toBeUndefined()
     expect(connection.handler).toBeUndefined()
   })
   })
 
 
+  it('claims and validates in-process Remote event results for the active Client generation', async () => {
+    const ctx = new Context()
+    await ctx.plugin(TypertRegistry)
+    await ctx.plugin(FakeConnectionService)
+    await ctx.plugin(TypertGatewayService)
+    const connection = rawConnection(ctx)
+    const handler = connection.handler
+    if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
+    expect(connection.matches?.('$events/result')).toBe(true)
+
+    const result = {
+      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' } })
+    if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded')
+    expect(inactive.error.message).toContain('identifies no active event stream')
+
+    const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () {
+      await new Promise<void>((resolve) => {
+        if (signal.aborted) resolve()
+        else signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    })())
+    const carrier = new AbortController()
+    const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal)
+    const opening = await events.next()
+    expect(opening).toMatchObject({ done: false, value: { type: 'ready' } })
+    if (opening.done) throw new Error('Remote event stream ended before ready')
+    const clientId: unknown = Reflect.get(opening.value as object, 'clientId')
+    if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id')
+
+    for (const payload of [null, [], {}, { other: {} }]) {
+      const invalid = await handler('$events/result', payload, carrier.signal)
+      expect(invalid).toMatchObject({ ok: false, error: { code: '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')
+    }
+    await expect(handler('$events/result', {
+      args: { clientId, eventId: 'missing', outcome: { kind: 'next' } },
+    }, carrier.signal)).resolves.toEqual({
+      ok: true,
+      value: undefined,
+    })
+
+    await events.return(undefined)
+    await unregister()
+    await ctx.fiber.dispose()
+  })
+
   it('preserves a lookup policy rejection through the Connection RPC result', async () => {
   it('preserves a lookup policy rejection through the Connection RPC result', async () => {
     const ctx = new Context()
     const ctx = new Context()
     await ctx.plugin(TypertRegistry)
     await ctx.plugin(TypertRegistry)
@@ -1228,6 +1278,17 @@ function rawConnection(ctx: Context): FakeConnectionService {
   return receiver[symbols.original] ?? receiver
   return receiver[symbols.original] ?? receiver
 }
 }
 
 
+interface GatewayEventHarness {
+  openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator
+}
+
+function rawGatewayEventHarness(ctx: Context): GatewayEventHarness {
+  const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & {
+    [symbols.original]?: GatewayEventHarness
+  }
+  return receiver[symbols.original] ?? receiver
+}
+
 function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise<void> {
 function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise<void> {
   return ctx.typert.register({
   return ctx.typert.register({
     package: '@fixture/gateway',
     package: '@fixture/gateway',
@@ -1256,6 +1317,7 @@ function contextProvider(context: Context) {
   return {
   return {
     wire: 'agentId',
     wire: 'agentId',
     wireTypeSymbol: '@fixture/domain#AgentId',
     wireTypeSymbol: '@fixture/domain#AgentId',
+    identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined,
     resolve: (id: string) => id === 'agent-1' ? context : undefined,
     resolve: (id: string) => id === 'agent-1' ? context : undefined,
   }
   }
 }
 }

+ 335 - 0
packages/api/gateway/tests/journal-stream.client.spec.ts

@@ -0,0 +1,335 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+  RemoteJournalStream,
+  RemoteStream,
+  RemoteStreamCarrierError,
+  type RemoteJournalChange,
+  type RemoteJournalFrame,
+  type RemoteStreamOptions,
+} from '../src/client/index.ts'
+
+interface Entry {
+  readonly seq: number
+}
+
+interface Page {
+  readonly entries: readonly Entry[]
+  readonly hasMore: boolean
+  readonly marker: string
+}
+
+interface PageRequest {
+  readonly before?: number
+  readonly limit?: number
+}
+
+interface Generation {
+  readonly frames: readonly RemoteJournalFrame<Entry, number>[]
+  readonly terminal?: Error
+  readonly hold?: boolean
+}
+
+const AVAILABLE_CONNECTION = {
+  hostDescription: {
+    getSnapshot: () => ({
+      version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
+    }),
+    subscribe: () => () => {},
+  },
+}
+
+const entries = (...seqs: number[]): Entry[] => seqs.map(seq => ({ seq }))
+
+const page = (marker: string, seqs: number[], hasMore = false): Page => ({
+  entries: entries(...seqs),
+  hasMore,
+  marker,
+})
+
+const STREAM_FACTORY = {
+  $stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item> {
+    return new RemoteStream(AVAILABLE_CONNECTION, options)
+  },
+}
+
+class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageRequest> {
+  constructor(
+    private readonly generations: Generation[],
+    private readonly pages: (Page | Promise<Page>)[],
+    private readonly calls: string[],
+    private readonly pageRequests: PageRequest[],
+    private readonly followCursors: (number | undefined)[],
+    changes: RemoteJournalChange<Page, Entry>[],
+    failed: (error: unknown) => void,
+  ) {
+    super(STREAM_FACTORY, {
+      name: 'fixture journal',
+      emptyCursor: -1,
+      entries: value => value.entries,
+      hasMore: value => value.hasMore,
+      cursor: entry => entry.seq,
+      compare: (left, right) => left - right,
+      follows: (left, right) => right === left + 1,
+      publish: (change) => { changes.push(change) },
+      failed,
+    })
+  }
+
+  /** @inheritdoc */
+  protected override async * follow(
+    after: number | undefined,
+    signal: AbortSignal,
+  ): AsyncIterable<RemoteJournalFrame<Entry, number>> {
+    this.calls.push('follow')
+    this.followCursors.push(after)
+    const generation = this.generations.shift()
+    if (generation === undefined) throw new Error('no scripted journal generation')
+    for (const frame of generation.frames) yield frame
+    if (generation.terminal !== undefined) throw generation.terminal
+    if (generation.hold === true && !signal.aborted) {
+      await new Promise<void>((resolve) => {
+        signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    }
+  }
+
+  /** @inheritdoc */
+  protected override readPage(request: PageRequest): Promise<Page> {
+    this.calls.push('page')
+    this.pageRequests.push(request)
+    const value = this.pages.shift()
+    if (value === undefined) throw new Error('no scripted journal page')
+    return Promise.resolve(value)
+  }
+
+  /** @inheritdoc */
+  protected override repairRequest(request: PageRequest): PageRequest {
+    return request.limit === undefined ? {} : { limit: request.limit }
+  }
+}
+
+function journalFixture(
+  generations: Generation[],
+  pages: (Page | Promise<Page>)[],
+): {
+  readonly journal: RemoteJournalStream<Page, Entry, number, PageRequest>
+  readonly changes: RemoteJournalChange<Page, Entry>[]
+  readonly failed: ReturnType<typeof vi.fn>
+  readonly calls: string[]
+  readonly pageRequests: PageRequest[]
+  readonly followCursors: (number | undefined)[]
+} {
+  const calls: string[] = []
+  const pageRequests: PageRequest[] = []
+  const followCursors: (number | undefined)[] = []
+  const changes: RemoteJournalChange<Page, Entry>[] = []
+  const failed = vi.fn()
+  const journal = new FixtureJournal(
+    generations,
+    pages,
+    calls,
+    pageRequests,
+    followCursors,
+    changes,
+    failed,
+  )
+  return { journal, changes, failed, calls, pageRequests, followCursors }
+}
+
+describe('RemoteJournalStream', () => {
+  it('opens follow before page, removes overlap, appends live entries, and prepends history', async () => {
+    const fixture = journalFixture(
+      [{
+        frames: [
+          { type: 'opened', cursor: 3 },
+          { type: 'entry', entry: { seq: 3 } },
+          { type: 'entry', entry: { seq: 4 } },
+        ],
+        hold: true,
+      }],
+      [page('tail', [2, 3], true), page('older', [0, 1])],
+    )
+
+    await fixture.journal.open({ limit: 2 })
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
+    await fixture.journal.prepend({ before: 2, limit: 2 })
+
+    expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page'])
+    expect(fixture.pageRequests).toEqual([{ limit: 2 }, { before: 2, limit: 2 }])
+    expect(fixture.changes).toEqual([
+      { type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true },
+      { type: 'append', entry: { seq: 4 } },
+      { type: 'prepend', page: page('older', [0, 1]), entries: entries(0, 1), hasMore: false },
+    ])
+    await fixture.journal.dispose()
+    await fixture.journal.dispose()
+  })
+
+  it('publishes one sorted replacement from a repair page and live entries queued while it loads', async () => {
+    let resolveRepair!: (value: Page) => void
+    const repair = new Promise<Page>((resolve) => { resolveRepair = resolve })
+    const fixture = journalFixture(
+      [{
+        frames: [
+          { type: 'opened', cursor: 15 },
+          { type: 'entry', entry: { seq: 17 } },
+          { type: 'entry', entry: { seq: 16 } },
+        ],
+        hold: true,
+      }],
+      [page('stale', [6, 7, 8, 9, 10, 11]), repair],
+    )
+
+    const opening = fixture.journal.open({ limit: 6 })
+    await vi.waitFor(() => {
+      expect(fixture.calls.filter(call => call === 'page')).toHaveLength(2)
+    })
+    expect(fixture.changes).toEqual([])
+
+    resolveRepair(page('repair', [10, 11, 12, 13, 14, 15]))
+    await opening
+
+    expect(fixture.changes).toEqual([{
+      type: 'replace',
+      page: page('repair', [10, 11, 12, 13, 14, 15]),
+      entries: entries(10, 11, 12, 13, 14, 15, 16, 17),
+      hasMore: false,
+    }])
+    await fixture.journal.dispose()
+  })
+
+  it('repairs a replacement generation through one tail page and drops replay overlap', async () => {
+    const lost = new RemoteStreamCarrierError('carrier lost')
+    const fixture = journalFixture(
+      [
+        {
+          frames: [
+            { type: 'opened', cursor: 1 },
+            { type: 'entry', entry: { seq: 2 } },
+          ],
+          terminal: lost,
+        },
+        {
+          frames: [
+            { type: 'opened', cursor: 4 },
+            { type: 'entry', entry: { seq: 3 } },
+            { type: 'entry', entry: { seq: 4 } },
+          ],
+          hold: true,
+        },
+      ],
+      [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])],
+    )
+
+    await fixture.journal.open({ limit: 5 })
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(3) })
+
+    expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
+    expect(fixture.changes[2]).toMatchObject({
+      type: 'replace', page: { marker: 'repair' }, entries: entries(0, 1, 2, 3, 4),
+    })
+    expect(fixture.followCursors).toEqual([undefined, 2])
+    expect(fixture.failed).not.toHaveBeenCalled()
+    await fixture.journal.dispose()
+  })
+
+  it('repairs a live gap before publishing another change', async () => {
+    const fixture = journalFixture(
+      [{
+        frames: [
+          { type: 'opened', cursor: 1 },
+          { type: 'entry', entry: { seq: 4 } },
+        ],
+        hold: true,
+      }],
+      [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])],
+    )
+
+    await fixture.journal.open({})
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
+
+    expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace'])
+    expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } })
+    await fixture.journal.dispose()
+  })
+
+  it('rejects malformed opening and page sequences', async () => {
+    const beforeOpening = journalFixture(
+      [{ frames: [{ type: 'entry', entry: { seq: 0 } }] }],
+      [page('unused', [])],
+    )
+    await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor')
+
+    const discontinuousPage = journalFixture(
+      [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }],
+      [page('bad', [0, 2, 3])],
+    )
+    await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries')
+
+    const shortPage = journalFixture(
+      [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }],
+      [page('short', [0, 1]), page('repair-short', [0, 1, 2])],
+    )
+    await expect(shortPage.journal.open({})).rejects.toThrow('page did not reach its opening cursor')
+  })
+
+  it('reports duplicate and regressed generation cursors as terminal failures', async () => {
+    const duplicate = journalFixture(
+      [{
+        frames: [{ type: 'opened', cursor: 1 }, { type: 'opened', cursor: 1 }],
+      }],
+      [page('initial', [0, 1])],
+    )
+    await duplicate.journal.open({})
+    await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() })
+    const duplicateFailure: unknown = duplicate.failed.mock.calls[0]?.[0]
+    expect(duplicateFailure).toBeInstanceOf(Error)
+    if (!(duplicateFailure instanceof Error)) throw new Error('expected duplicate-cursor failure')
+    expect(duplicateFailure.message).toContain('more than one opening cursor')
+
+    const regressed = journalFixture(
+      [
+        {
+          frames: [{ type: 'opened', cursor: 1 }, { type: 'entry', entry: { seq: 2 } }],
+          terminal: new RemoteStreamCarrierError('lost'),
+        },
+        { frames: [{ type: 'opened', cursor: 1 }] },
+      ],
+      [page('initial', [0, 1])],
+    )
+    await regressed.journal.open({})
+    await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() })
+    const regressedFailure: unknown = regressed.failed.mock.calls[0]?.[0]
+    expect(regressedFailure).toBeInstanceOf(Error)
+    if (!(regressedFailure instanceof Error)) throw new Error('expected regressed-cursor failure')
+    expect(regressedFailure.message).toContain('behind the last applied entry')
+  })
+
+  it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => {
+    const fixture = journalFixture(
+      [{ frames: [{ type: 'opened', cursor: 4 }], hold: true }],
+      [page('initial', [3, 4], true), page('older', [0, 1], true)],
+    )
+    await fixture.journal.open({})
+
+    await expect(fixture.journal.prepend({ before: 3 })).rejects.toThrow('history page is discontinuous')
+    expect(fixture.changes.at(-1)).toEqual({
+      type: 'prepend', page: page('older', [0, 1], true), entries: [], hasMore: false,
+    })
+    await fixture.journal.dispose()
+  })
+
+  it('guards lifecycle operations before and after open', async () => {
+    const fixture = journalFixture(
+      [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
+      [page('empty', [])],
+    )
+
+    await expect(fixture.journal.prepend({})).rejects.toThrow('is not open')
+    await fixture.journal.open({})
+    await expect(fixture.journal.open({})).rejects.toThrow('already opened')
+    fixture.journal.restart()
+    await fixture.journal.dispose()
+    await expect(fixture.journal.prepend({})).rejects.toThrow('is not open')
+  })
+})

+ 287 - 0
packages/api/gateway/tests/remote-event-protocol.host.spec.ts

@@ -0,0 +1,287 @@
+import { describe, expect, it } from 'vitest'
+import {
+  isRemoteJsonValue,
+  parseRemoteEventResult,
+  parseRemoteStreamClientMessage,
+  projectRemoteEventRequest,
+  projectRemoteEventRejection,
+  restoreRemoteEventRejection,
+} from '../src/stream-protocol.ts'
+
+describe('Remote Event result protocol', () => {
+  it('accepts delegation, values, and structured rejections', () => {
+    expect(parseRemoteEventResult({
+      clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' },
+    })).toEqual({ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' } })
+    expect(parseRemoteEventResult({
+      clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' },
+    })).toEqual({ clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' } })
+    expect(parseRemoteEventResult({
+      clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } },
+    })).toEqual({
+      clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } },
+    })
+    expect(parseRemoteEventResult({
+      clientId: 'client-1',
+      eventId: 'event-minimal',
+      outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } },
+    })).toEqual({
+      clientId: 'client-1',
+      eventId: 'event-minimal',
+      outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } },
+    })
+    expect(parseRemoteEventResult({
+      clientId: 'client-1',
+      eventId: 'event-4',
+      outcome: {
+        kind: 'rejected',
+        error: {
+          name: 'ApprovalError',
+          message: 'declined',
+          code: 'DECLINED',
+          details: { retryable: false },
+        },
+      },
+    })).toEqual({
+      clientId: 'client-1',
+      eventId: 'event-4',
+      outcome: {
+        kind: 'rejected',
+        error: {
+          name: 'ApprovalError',
+          message: 'declined',
+          code: 'DECLINED',
+          details: { retryable: false },
+        },
+      },
+    })
+  })
+
+  it.each([
+    null,
+    [],
+    {},
+    { clientId: '', eventId: 'event-1', outcome: { kind: 'next' } },
+    { clientId: 'client-1', eventId: '', outcome: { kind: 'next' } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: null },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, extra: true },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next', value: null } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', extra: true } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', value: undefined } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'unknown' } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: null } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: '', message: 'bad' } } },
+    { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: 'Error', message: 1 } } },
+    {
+      clientId: 'client-1',
+      eventId: 'event-1',
+      outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', code: 1 } },
+    },
+    {
+      clientId: 'client-1',
+      eventId: 'event-1',
+      outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', details: 1n } },
+    },
+    {
+      clientId: 'client-1',
+      eventId: 'event-1',
+      outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', extra: true } },
+    },
+  ])('rejects an invalid result frame: %#', (value) => {
+    expect(() => parseRemoteEventResult(value)).toThrow('api gateway: invalid Remote event')
+  })
+
+  it('rejects symbol properties in rejection records', () => {
+    const error = { name: 'Error', message: 'bad', [Symbol('hidden')]: true }
+    expect(() => parseRemoteEventResult({
+      clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error },
+    })).toThrow('api gateway: invalid Remote event rejection')
+  })
+})
+
+describe('Remote Event request projection', () => {
+  it('removes only the direct Agent and signal fields', () => {
+    const agent = { kind: 'agent' }
+    const abort = new AbortController()
+    const nested = { agent, signal: 'payload' }
+    const projected = projectRemoteEventRequest({
+      agent,
+      signal: abort.signal,
+      prompt: 'approve?',
+      nested,
+    }, agent)
+
+    expect(projected).toEqual({
+      request: { prompt: 'approve?', nested },
+      signal: abort.signal,
+    })
+    expect(Object.getPrototypeOf(projected.request)).toBeNull()
+  })
+
+  it('accepts a null-prototype request and an omitted signal', () => {
+    const agent = { kind: 'agent' }
+    const request = Object.assign(Object.create(null) as Record<string, unknown>, {
+      agent,
+      accepted: true,
+    })
+    expect(projectRemoteEventRequest(request, agent)).toEqual({
+      request: { accepted: true },
+    })
+  })
+
+  it('requires the scoped Agent as a direct own field', () => {
+    const agent = { kind: 'agent' }
+    expect(() => projectRemoteEventRequest(null, agent))
+      .toThrow('must carry its scoped Agent directly')
+    expect(() => projectRemoteEventRequest({}, agent))
+      .toThrow('must carry its scoped Agent directly')
+    expect(() => projectRemoteEventRequest({ agent: {} }, agent))
+      .toThrow('must carry its scoped Agent directly')
+    expect(() => projectRemoteEventRequest(Object.create({ agent }), agent))
+      .toThrow('must carry its scoped Agent directly')
+  })
+
+  it('rejects an invalid direct signal', () => {
+    const agent = { kind: 'agent' }
+    expect(() => projectRemoteEventRequest({ agent, signal: 'abort' }, agent))
+      .toThrow('request signal must be an AbortSignal')
+  })
+
+  it('rejects non-JSON payload fields', () => {
+    const agent = { kind: 'agent' }
+    expect(() => projectRemoteEventRequest({ agent, value: 1n }, agent))
+      .toThrow('request is not lossless JSON data')
+
+    const cycle: Record<string, unknown> = {}
+    cycle.self = cycle
+    expect(() => projectRemoteEventRequest({ agent, cycle }, agent))
+      .toThrow('request is not lossless JSON data')
+  })
+
+  it('rejects symbol and non-enumerable payload fields', () => {
+    const agent = { kind: 'agent' }
+    expect(() => projectRemoteEventRequest({ agent, [Symbol('hidden')]: true }, agent))
+      .toThrow('request has a non-JSON property')
+
+    const hidden = { agent }
+    Object.defineProperty(hidden, 'value', { value: true })
+    expect(() => projectRemoteEventRequest(hidden, agent))
+      .toThrow('request has a non-JSON property')
+  })
+})
+
+describe('Remote Event rejection projection', () => {
+  it('preserves stable error fields in both directions', () => {
+    const reason = Object.assign(new Error('declined'), {
+      name: 'ApprovalError',
+      code: 'DECLINED',
+      details: { retryable: false },
+    })
+    expect(projectRemoteEventRejection(reason)).toEqual({
+      name: 'ApprovalError',
+      message: 'declined',
+      code: 'DECLINED',
+      details: { retryable: false },
+    })
+
+    const restored = restoreRemoteEventRejection({
+      name: 'ApprovalError',
+      message: 'declined',
+      code: 'DECLINED',
+      details: { retryable: false },
+    }) as Error & { code?: string; details?: unknown }
+    expect(restored).toMatchObject({
+      name: 'ApprovalError',
+      message: 'declined',
+      code: 'DECLINED',
+      details: { retryable: false },
+    })
+  })
+
+  it('normalizes arbitrary reasons and omits non-JSON optional fields', () => {
+    expect(projectRemoteEventRejection('offline')).toEqual({
+      name: 'Error', message: 'offline',
+    })
+    expect(projectRemoteEventRejection(undefined)).toEqual({
+      name: 'Error', message: 'undefined',
+    })
+    expect(projectRemoteEventRejection({
+      name: 1, message: 2, code: 3, details: 1n,
+    })).toEqual({
+      name: 'Error', message: '[object Object]',
+    })
+
+    const restored = restoreRemoteEventRejection({ name: 'Error', message: 'offline' })
+    expect(restored).toMatchObject({ name: 'Error', message: 'offline' })
+    expect(restored).not.toHaveProperty('code')
+    expect(restored).not.toHaveProperty('details')
+  })
+})
+
+describe('Remote Event JSON values', () => {
+  it('accepts lossless JSON values, null-prototype objects, and repeated references', () => {
+    const shared = { value: 1 }
+    const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, {
+      enabled: true,
+    })
+    expect(isRemoteJsonValue({
+      null: null,
+      string: 'value',
+      boolean: true,
+      number: 1.5,
+      array: [shared, shared],
+      nullPrototype,
+    })).toBe(true)
+  })
+
+  it.each([
+    undefined,
+    1n,
+    Symbol('value'),
+    () => undefined,
+    NaN,
+    Number.POSITIVE_INFINITY,
+    -0,
+  ])('rejects a non-lossless scalar: %s', (value) => {
+    expect(isRemoteJsonValue(value)).toBe(false)
+  })
+
+  it('rejects cycles and non-plain arrays and objects', () => {
+    const cycle: Record<string, unknown> = {}
+    cycle.self = cycle
+    expect(isRemoteJsonValue(cycle)).toBe(false)
+
+    class Fixture {
+      value = 1
+    }
+    expect(isRemoteJsonValue(new Fixture())).toBe(false)
+
+    const customArray = [1]
+    Object.setPrototypeOf(customArray, null)
+    expect(isRemoteJsonValue(customArray)).toBe(false)
+    expect(isRemoteJsonValue(Object.assign([1], { extra: true }))).toBe(false)
+
+    const sparse = new Array<unknown>(2)
+    sparse[1] = 'value'
+    expect(isRemoteJsonValue(sparse)).toBe(false)
+    const disguisedSparse = Object.assign(new Array<unknown>(2), { extra: true })
+    disguisedSparse[1] = 'value'
+    expect(isRemoteJsonValue(disguisedSparse)).toBe(false)
+    expect(isRemoteJsonValue([undefined])).toBe(false)
+
+    const symbolic = { [Symbol('value')]: true }
+    expect(isRemoteJsonValue(symbolic)).toBe(false)
+    const hidden = {}
+    Object.defineProperty(hidden, 'value', { value: true })
+    expect(isRemoteJsonValue(hidden)).toBe(false)
+    expect(isRemoteJsonValue({ nested: undefined })).toBe(false)
+  })
+})
+
+describe('Remote stream client protocol', () => {
+  it('rejects the removed logical-stream input message', () => {
+    expect(() => parseRemoteStreamClientMessage(JSON.stringify({
+      type: 'input', streamId: 'stream-1', value: { answer: true },
+    }))).toThrow('api gateway: invalid Remote stream client message')
+  })
+})

+ 68 - 0
packages/api/gateway/tests/stream-protocol.host.spec.ts

@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import {
+  parseRemoteStreamClientMessage,
+  parseRemoteStreamServerMessage,
+} from '../src/stream-protocol.ts'
+
+describe('Remote stream wire protocol', () => {
+  it('accepts every client message variant', () => {
+    expect(parseRemoteStreamClientMessage(JSON.stringify({
+      type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 },
+    }))).toEqual({
+      type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 },
+    })
+    expect(parseRemoteStreamClientMessage(JSON.stringify({
+      type: 'cancel', streamId: 'stream-1',
+    }))).toEqual({ type: 'cancel', streamId: 'stream-1' })
+  })
+
+  it.each([
+    { type: 'open', streamId: '', endpoint: 'feed/follow', payload: {} },
+    { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} },
+    { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' },
+    { type: 'cancel', streamId: 'stream-1', extra: true },
+    { type: 'unknown', streamId: 'stream-1' },
+  ])('rejects an invalid client message: %j', (message) => {
+    expect(() => parseRemoteStreamClientMessage(JSON.stringify(message)))
+      .toThrow('api gateway: invalid Remote stream client message')
+  })
+
+  it('accepts every server message variant', () => {
+    expect(parseRemoteStreamServerMessage(JSON.stringify({
+      type: 'item', streamId: 'stream-1', value: null,
+    }))).toEqual({ type: 'item', streamId: 'stream-1', value: null })
+    expect(parseRemoteStreamServerMessage(JSON.stringify({
+      type: 'item', streamId: 'stream-1',
+    }))).toEqual({ type: 'item', streamId: 'stream-1' })
+    expect(parseRemoteStreamServerMessage(JSON.stringify({
+      type: 'error',
+      streamId: 'stream-1',
+      error: { code: 'offline', message: 'connection lost', details: {} },
+    }))).toEqual({
+      type: 'error',
+      streamId: 'stream-1',
+      error: { code: 'offline', message: 'connection lost', details: {} },
+    })
+    expect(parseRemoteStreamServerMessage(JSON.stringify({
+      type: 'end', streamId: 'stream-1',
+    }))).toEqual({ type: 'end', streamId: 'stream-1' })
+  })
+
+  it.each([
+    { type: 'item', streamId: '', value: 'item' },
+    { type: 'item', streamId: 'stream-1', extra: true },
+    { type: 'end', streamId: 'stream-1', extra: true },
+    { type: 'error', streamId: 'stream-1', error: [] },
+    { type: 'error', streamId: 'stream-1', error: { code: 1, message: 'failure', details: {} } },
+    { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 1, details: {} } },
+    { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 'failure', details: [] } },
+    { type: 'unknown', streamId: 'stream-1' },
+  ])('rejects an invalid server message: %j', (message) => {
+    expect(() => parseRemoteStreamServerMessage(JSON.stringify(message)))
+      .toThrow('api gateway: invalid Remote stream server message')
+  })
+
+  it.each(['not json', 'null', '[]', '1'])('rejects a non-message payload: %s', (text) => {
+    expect(() => parseRemoteStreamServerMessage(text)).toThrow('api gateway: Remote stream message')
+  })
+})

+ 246 - 0
packages/api/gateway/tests/stream-server.host.spec.ts

@@ -0,0 +1,246 @@
+import { once } from 'node:events'
+import { createServer, type Server } from 'node:http'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import WebSocket from 'ws'
+import {
+  RemoteStreamMuxServer,
+  type RemoteStreamFailureMapper,
+  type RemoteStreamOpener,
+} from '../src/stream-server.ts'
+
+interface RunningMux {
+  readonly http: Server
+  readonly mux: RemoteStreamMuxServer
+  readonly url: string
+}
+
+const running = new Set<RunningMux>()
+
+afterEach(async () => {
+  await Promise.all([...running].map(async (entry) => {
+    running.delete(entry)
+    await entry.mux.close().catch(() => undefined)
+    await closeHttp(entry.http)
+  }))
+})
+
+describe('Remote stream mux server carrier lifecycle', () => {
+  it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
+    const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
+
+    const binary = await connect(entry.url)
+    const binaryClosed = once(binary, 'close')
+    binary.send(Buffer.from('{}'))
+    const binaryEvent = await binaryClosed
+    expect(binaryEvent[0]).toBe(1003)
+
+    const malformed = await connect(entry.url)
+    const malformedClosed = once(malformed, 'close')
+    malformed.send('not json')
+    const malformedEvent = await malformedClosed
+    expect(malformedEvent[0]).toBe(1008)
+    expect(String(malformedEvent[1])).toBe('invalid Remote stream request')
+
+    const duplicate = await connect(entry.url)
+    const longId = 'same'.repeat(100)
+    duplicate.send(openFrame(longId))
+    duplicate.send(openFrame(longId))
+    const duplicateEvent = await once(duplicate, 'close')
+    expect(duplicateEvent[0]).toBe(1008)
+    expect(String(duplicateEvent[1])).toBe('invalid Remote stream request')
+
+    const noInput = await connect(entry.url)
+    noInput.send(openFrame('no-input'))
+    noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' }))
+    const noInputEvent = await once(noInput, 'close')
+    expect(noInputEvent[0]).toBe(1008)
+    expect(String(noInputEvent[1])).toBe('invalid Remote stream request')
+  })
+
+  it('accepts all ws text representations and terminates a carrier error', async () => {
+    const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
+    const client = await connect(entry.url)
+    const serverSocket = acceptedSocket(entry.mux)
+    const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' })
+
+    serverSocket.emit('message', [Buffer.from(cancel)], false)
+    serverSocket.emit('message', Uint8Array.from(Buffer.from(cancel)).buffer, false)
+
+    const closed = once(client, 'close')
+    serverSocket.emit('error', new Error('fixture carrier failure'))
+    await closed
+  })
+
+  it('does not send an end frame after clean source cancellation', async () => {
+    let opened!: () => void
+    const didOpen = new Promise<void>((resolve) => { opened = resolve })
+    let returned!: () => void
+    const didReturn = new Promise<void>((resolve) => { returned = resolve })
+    const entry = await startMux(async (_endpoint, _payload, signal) => {
+      opened()
+      return cleanlyCancelled(signal, returned)
+    })
+    const client = await connect(entry.url)
+    const frames: unknown[] = []
+    client.on('message', (data) => {
+      if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame')
+      frames.push(JSON.parse(data.toString('utf8')) as unknown)
+    })
+    client.send(openFrame('cancelled'))
+    await didOpen
+    client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' }))
+    await didReturn
+    await new Promise<void>((resolve) => { setImmediate(resolve) })
+    expect(frames).toEqual([])
+    client.close()
+    await once(client, 'close')
+  })
+
+  it('closes the carrier when ws reports an item write failure', async () => {
+    let release!: () => void
+    const released = new Promise<void>((resolve) => { release = resolve })
+    let opened!: () => void
+    const didOpen = new Promise<void>((resolve) => { opened = resolve })
+    const entry = await startMux(async () => delayedItem(released, opened))
+    const client = await connect(entry.url)
+    client.send(openFrame('write-failure'))
+    await didOpen
+    const serverSocket = acceptedSocket(entry.mux)
+    const mutable = serverSocket as unknown as {
+      send(data: unknown, callback: (error?: Error) => void): void
+    }
+    mutable.send = (_data, callback): void => {
+      callback(new Error('fixture ws write failure'))
+    }
+
+    const closed = once(client, 'close')
+    release()
+    const closeEvent = await closed
+    expect(closeEvent[0]).toBe(1011)
+    expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
+  })
+
+  it('contains an item produced after its socket closes', async () => {
+    let release!: () => void
+    const released = new Promise<void>((resolve) => { release = resolve })
+    let opened!: () => void
+    const didOpen = new Promise<void>((resolve) => { opened = resolve })
+    let returned!: () => void
+    const didReturn = new Promise<void>((resolve) => { returned = resolve })
+    const entry = await startMux(async () => delayedItem(released, opened, returned))
+    const client = await connect(entry.url)
+    client.send(openFrame('late-item'))
+    await didOpen
+    const serverSocket = acceptedSocket(entry.mux)
+    client.close()
+    await once(client, 'close')
+    await vi.waitFor(() => { expect(serverSocket.readyState).toBe(WebSocket.CLOSED) })
+    release()
+    await didReturn
+  })
+
+  it('terminates active sockets on close and reports a repeated close', async () => {
+    let opened!: () => void
+    const didOpen = new Promise<void>((resolve) => { opened = resolve })
+    let returned!: () => void
+    const didReturn = new Promise<void>((resolve) => { returned = resolve })
+    const entry = await startMux(async (_endpoint, _payload, signal) => {
+      opened()
+      return cleanlyCancelled(signal, returned)
+    })
+    const client = await connect(entry.url)
+    client.send(openFrame('active'))
+    await didOpen
+
+    const closed = once(client, 'close')
+    await entry.mux.close()
+    running.delete(entry)
+    await closed
+    await didReturn
+    await expect(entry.mux.close()).rejects.toThrow()
+    await closeHttp(entry.http)
+  })
+})
+
+const mapFailure: RemoteStreamFailureMapper = error => ({
+  code: 'internal',
+  message: error instanceof Error ? error.message : String(error),
+  details: {},
+})
+
+async function startMux(open: RemoteStreamOpener): Promise<RunningMux> {
+  const mux = new RemoteStreamMuxServer(open, mapFailure)
+  const http = createServer()
+  http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) })
+  await new Promise<void>((resolve, reject) => {
+    http.once('error', reject)
+    http.listen(0, '127.0.0.1', () => {
+      http.off('error', reject)
+      resolve()
+    })
+  })
+  const address = http.address()
+  if (address === null || typeof address === 'string') throw new Error('fixture HTTP server has no TCP port')
+  const entry = { http, mux, url: `ws://127.0.0.1:${String(address.port)}` }
+  running.add(entry)
+  return entry
+}
+
+async function connect(url: string): Promise<WebSocket> {
+  const socket = new WebSocket(url)
+  await once(socket, 'open')
+  return socket
+}
+
+function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket {
+  const exposed = mux as unknown as { server: { clients: Set<WebSocket> } }
+  const socket = [...exposed.server.clients][0]
+  if (socket === undefined) throw new Error('fixture mux has no accepted socket')
+  return socket
+}
+
+function openFrame(streamId: string): string {
+  return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} })
+}
+
+async function *waitForAbort(signal: AbortSignal): AsyncIterable<never> {
+  await new Promise<void>((resolve) => {
+    if (signal.aborted) resolve()
+    else signal.addEventListener('abort', () => { resolve() }, { once: true })
+  })
+}
+
+async function *cleanlyCancelled(signal: AbortSignal, returned: () => void): AsyncIterable<never> {
+  try {
+    await new Promise<void>((resolve) => {
+      if (signal.aborted) resolve()
+      else signal.addEventListener('abort', () => { resolve() }, { once: true })
+    })
+  } finally {
+    returned()
+  }
+}
+
+async function *delayedItem(
+  released: Promise<void>,
+  opened: () => void,
+  returned: () => void = () => {},
+): AsyncIterable<string> {
+  try {
+    opened()
+    await released
+    yield 'item'
+  } finally {
+    returned()
+  }
+}
+
+async function closeHttp(server: Server): Promise<void> {
+  if (!server.listening) return
+  await new Promise<void>((resolve, reject) => {
+    server.close((error) => {
+      if (error === undefined) resolve()
+      else reject(error)
+    })
+  })
+}

+ 1 - 1
packages/core/agent/src/dispatch.ts

@@ -10,7 +10,7 @@ import type { Context, Events } from '@deepseek-ai/cordis'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
 import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
-import type { Agent } from './runtime-types.ts'
+import type { Agent } from './types.ts'
 
 
 /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
 /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
 type Params<F> = F extends (...args: infer P) => unknown ? P : never
 type Params<F> = F extends (...args: infer P) => unknown ? P : never

+ 3 - 12
packages/core/agent/src/index.ts

@@ -12,8 +12,8 @@ import { isPromise } from 'node:util/types'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
-import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
-import type { Agent, AgentOptions } from './runtime-types.ts'
+import type { Agent } from './types.ts'
+import type { AgentOptions } from './runtime-types.ts'
 
 
 export * from './runtime-types.ts'
 export * from './runtime-types.ts'
 export * from './types.ts'
 export * from './types.ts'
@@ -23,16 +23,6 @@ export * from './model-selection.ts'
 export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
 export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
 export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
 export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
 
 
-declare module '@deepseek-ai/dsh-typert-protocol' {
-  interface TypertLookupMap {
-    agent: TypertLookup<Agent, SessionId>
-  }
-
-  interface TypertContextMap {
-    agent: TypertContext<SessionId>
-  }
-}
-
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
   interface Context {
   interface Context {
     agents: AgentRegistry
     agents: AgentRegistry
@@ -276,6 +266,7 @@ export class AgentRegistry extends Service {
       typeCtx.typert.contexts.registerHost('agent', {
       typeCtx.typert.contexts.registerHost('agent', {
         wire: 'agentId',
         wire: 'agentId',
         wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
         wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
+        identity: candidate => candidate.agent?.id,
         resolve: sessionId => this.get(sessionId)?.ctx,
         resolve: sessionId => this.get(sessionId)?.ctx,
       })
       })
     })
     })

+ 31 - 30
packages/core/agent/src/runtime-types.ts

@@ -8,10 +8,11 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
 import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
-import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
+import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session'
 export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
 export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
 import type { Inbox } from './inbox.ts'
 import type { Inbox } from './inbox.ts'
-import type { InboxTarget } from './types.ts'
+import type { Agent } from './types.ts'
+export type { Agent } from './types.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 declare module '@deepseek-ai/dsh-system-prompt' {
 declare module '@deepseek-ai/dsh-system-prompt' {
   interface AssembleContext {
   interface AssembleContext {
@@ -60,39 +61,38 @@ export type RequestErrorAction = { kind: 'retry' } | undefined
 /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
 /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
 export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
 export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
 
 
-/** Public live-agent handle. */
-export interface Agent {
-  /** The single identity shared with {@link session}. */
-  readonly id: SessionId
-  /** The provider route and model this agent's requests use. */
-  readonly options: AgentOptions
-  /** The live session this agent drives; its log is the durable source of truth. */
-  readonly session: Session
-  /** The agent-owned projection of durable pending work. */
-  readonly inbox: Inbox
-  /** The current lifecycle state, mirrored on every `agent/status` transition. */
-  readonly status: AgentStatus
-  /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
-  readonly ctx: Context
+declare module './types.ts' {
+  /** Public live-agent handle. */
+  interface Agent {
+    /** The provider route and model this agent's requests use. */
+    readonly options: AgentOptions
+    /** The live session this agent drives; its log is the durable source of truth. */
+    readonly session: Session
+    /** The agent-owned projection of durable pending work. */
+    readonly inbox: Inbox
+    /** The current lifecycle state, mirrored on every `agent/status` transition. */
+    readonly status: AgentStatus
+    /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
+    readonly ctx: Context
 
 
-  /**
+    /**
    * Clear queued and steering work — unless `keepInbox` — and abort the active
    * Clear queued and steering work — unless `keepInbox` — and abort the active
    * turn or between-turn task. The first cause wins for that activity. With no
    * turn or between-turn task. The first cause wins for that activity. With no
    * active activity, cancellation is a no-op and does not arm later work.
    * active activity, cancellation is a no-op and does not arm later work.
    * @param cause - the stable caller intent carried by the active operation signal.
    * @param cause - the stable caller intent carried by the active operation signal.
    * @param options - cancellation options; `keepInbox` preserves pending work.
    * @param options - cancellation options; `keepInbox` preserves pending work.
    */
    */
-  cancel(cause: AgentCancelCause, options?: CancelOptions): void
+    cancel(cause: AgentCancelCause, options?: CancelOptions): void
 
 
-  /**
+    /**
    * Resolve after the current whole-agent activity reaches quiescence. This
    * Resolve after the current whole-agent activity reaches quiescence. This
    * follows replacement work started before the observed driver retires,
    * follows replacement work started before the observed driver retires,
    * but does not identify the settlement of any particular message.
    * but does not identify the settlement of any particular message.
    * @returns fulfillment after no active driver or maintenance task remains.
    * @returns fulfillment after no active driver or maintenance task remains.
    */
    */
-  whenIdle(): Promise<void>
+    whenIdle(): Promise<void>
 
 
-  /**
+    /**
    * Run one non-turn maintenance task from the true idle phase. The task starts
    * Run one non-turn maintenance task from the true idle phase. The task starts
    * synchronously after claiming that phase; later waking input remains in the
    * synchronously after claiming that phase; later waking input remains in the
    * inbox until the task settles, while public status stays `idle`.
    * inbox until the task settles, while public status stays `idle`.
@@ -101,9 +101,9 @@ export interface Agent {
    * @throws synchronously when turn-driving or another maintenance task already owns the agent.
    * @throws synchronously when turn-driving or another maintenance task already owns the agent.
    * @returns the task promise.
    * @returns the task promise.
    */
    */
-  runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
+    runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
 
 
-  /**
+    /**
    * Route identified input to an inbox boundary and optionally wake the driver.
    * Route identified input to an inbox boundary and optionally wake the driver.
    * Waking input submitted after active cancellation is queued for the next
    * Waking input submitted after active cancellation is queued for the next
    * turn and runs when the aborted activity converges to idle; a `disposed`
    * turn and runs when the aborted activity converges to idle; a `disposed`
@@ -114,25 +114,25 @@ export interface Agent {
    * @param target - the preferred next-turn or next-step inbox boundary.
    * @param target - the preferred next-turn or next-step inbox boundary.
    * @param wakeup - whether delivery may wake the driver.
    * @param wakeup - whether delivery may wake the driver.
    */
    */
-  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
+    send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
 
 
-  /**
+    /**
    * Queue an ordinary follow-up turn and wake the driver. The item becomes the
    * Queue an ordinary follow-up turn and wake the driver. The item becomes the
    * sole ordinary message of its own turn.
    * sole ordinary message of its own turn.
    * @param message - identified prompt content and the source that supplied it.
    * @param message - identified prompt content and the source that supplied it.
    */
    */
-  followup(message: UserMessage): void
+    followup(message: UserMessage): void
 
 
-  /**
+    /**
    * Submit steering for the nearest step. An idle driver starts a turn;
    * Submit steering for the nearest step. An idle driver starts a turn;
    * a running driver consumes it at its next step boundary.
    * a running driver consumes it at its next step boundary.
    * A rejected step leaves steering parked in the inbox until the next
    * A rejected step leaves steering parked in the inbox until the next
    * wake; cancellation or disposal may discard pending steering.
    * wake; cancellation or disposal may discard pending steering.
    * @param message - identified steering content and the source that supplied it.
    * @param message - identified steering content and the source that supplied it.
    */
    */
-  steer(message: UserMessage): void
+    steer(message: UserMessage): void
 
 
-  /**
+    /**
    * Queue model-facing context for the next pre-step without waking the
    * Queue model-facing context for the next pre-step without waking the
    * driver. A running driver claims it at the nearest later step boundary;
    * driver. A running driver claims it at the nearest later step boundary;
    * idle drivers leave it pending until follow-up or steering
    * idle drivers leave it pending until follow-up or steering
@@ -140,7 +140,8 @@ export interface Agent {
    * batch. Cancellation or disposal may discard pending context.
    * batch. Cancellation or disposal may discard pending context.
    * @param message - identified injected context and the source that supplied it.
    * @param message - identified injected context and the source that supplied it.
    */
    */
-  inject(message: UserMessage): void
+    inject(message: UserMessage): void
+  }
 }
 }
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {

+ 19 - 0
packages/core/agent/src/types.ts

@@ -5,6 +5,25 @@
  */
  */
 
 
 import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
 import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
+
+/** Minimum Agent identity visible to cross-process event declarations. */
+export interface Agent {
+  /** Session-backed Agent identity. */
+  readonly id: SessionId
+}
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface TypertLookupMap {
+    agent: TypertLookup<Agent, SessionId>
+  }
+
+  interface TypertContextMap {
+    /** Agent Context identity shared by Host and Client adapters. */
+    agent: TypertContext<SessionId>
+  }
+}
 
 
 /** One of the two ordered pending-message lists owned by an agent. */
 /** One of the two ordered pending-message lists owned by an agent. */
 export type InboxTarget = 'next-turn' | 'next-step'
 export type InboxTarget = 'next-turn' | 'next-step'

+ 5 - 1
packages/core/agent/tests/agent.spec.ts

@@ -149,6 +149,7 @@ describe('AgentRegistry', () => {
     await agentFiber
     await agentFiber
     await ctx.plugin(TypertRegistry)
     await ctx.plugin(TypertRegistry)
     const agent = stubAgent('remote-agent')
     const agent = stubAgent('remote-agent')
+    Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) })
     const disposeAgent = ctx.agents.register(agent)
     const disposeAgent = ctx.agents.register(agent)
 
 
     const lookup = ctx.typert.lookups.get('agent')
     const lookup = ctx.typert.lookups.get('agent')
@@ -159,7 +160,10 @@ describe('AgentRegistry', () => {
       wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
       wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
     })
     })
     expect(lookup?.resolve(agent.id)).toBe(agent)
     expect(lookup?.resolve(agent.id)).toBe(agent)
-    expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx)
+    const context = ctx.typert.contexts.getHost('agent')
+    expect(context?.identity(agent.ctx)).toBe(agent.id)
+    expect(context?.identity(ctx)).toBeUndefined()
+    expect(context?.resolve(agent.id)).toBe(agent.ctx)
 
 
     disposeAgent()
     disposeAgent()
     expect(lookup?.resolve(agent.id)).toBeUndefined()
     expect(lookup?.resolve(agent.id)).toBeUndefined()

+ 3 - 5
packages/experimental/webworker-runtime/src/client/api-client.ts

@@ -1,9 +1,7 @@
 /**
 /**
- * Page-side API carrier over the postMessage tunnel. Only `doFetch` is
- * implemented: the streaming methods stay on `AbstractApiClient`'s default
- * `readSse`, which is exactly what the worker answers on the two event-stream
- * paths — so unary calls and downstream streams share one framing and neither
- * side needs a WebSocket.
+ * Page-side unary API carrier over the postMessage tunnel. Gateway Remote
+ * streams use the tunnel's dedicated logical-stream frames instead of this
+ * fetch-shaped API path.
  */
  */
 import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
 import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
 import type { WorkerTunnel } from './client.ts'
 import type { WorkerTunnel } from './client.ts'

+ 155 - 50
packages/experimental/webworker-runtime/src/client/client.ts

@@ -6,31 +6,16 @@
  */
  */
 
 
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
-
-/** Frame sent to the worker. */
-interface RequestFrame {
-  t: 'req'
-  id: number
-  method: string
-  /** Absolute URL; the worker derives `req.url` (pathname + search) from it. */
-  url: string
-  headers: Record<string, string>
-  body?: ArrayBuffer | undefined
-}
-
-/** Cancellation of an in-flight request or stream. */
-interface AbortFrame {
-  t: 'abort'
-  id: number
-}
-
-/** Frames received from the worker. */
-type ResponseFrame =
-  | { t: 'res'; id: number; status: number; headers: Record<string, string>; body?: ArrayBuffer; message?: string }
-  | { t: 'res-head'; id: number; status: number; headers: Record<string, string> }
-  | { t: 'res-chunk'; id: number; chunk: ArrayBuffer }
-  | { t: 'res-end'; id: number }
-  | { t: 'res-err'; id: number; message: string }
+import type {
+  TunnelAbortFrame as AbortFrame,
+  TunnelOutboundFrame as ResponseFrame,
+  TunnelRequestFrame as RequestFrame,
+  TunnelRequestId,
+  TunnelStreamEndFrame,
+  TunnelStreamErrorFrame,
+  TunnelStreamItemFrame,
+  TunnelStreamOpenFrame,
+} from '../transport/frames.ts'
 
 
 /** Boot payload of the tunnel bootstrap route. */
 /** Boot payload of the tunnel bootstrap route. */
 export interface BootPayload {
 export interface BootPayload {
@@ -46,6 +31,58 @@ interface PendingUnary {
   reject(reason: Error): void
   reject(reason: Error): void
 }
 }
 
 
+type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelStreamErrorFrame
+
+interface TunnelStreamFailureMarker {
+  readonly kind: 'remote' | 'carrier'
+  readonly code?: string
+  readonly details?: object
+}
+
+/** Error carrying stream semantics across independently bundled Client code. */
+class TunnelLogicalStreamError extends Error {
+  readonly dshRemoteStreamFailure: TunnelStreamFailureMarker
+
+  constructor(failure: TunnelStreamErrorFrame['failure'], options?: ErrorOptions) {
+    super(failure.message, options)
+    this.name = 'TunnelLogicalStreamError'
+    this.dshRemoteStreamFailure = failure.kind === 'remote'
+      ? { kind: 'remote', code: failure.code, details: failure.details }
+      : { kind: 'carrier' }
+  }
+}
+
+class LogicalStreamInbox {
+  private readonly frames: LogicalStreamFrame[] = []
+  private wake: (() => void) | undefined
+  private failed = false
+  private failure: unknown
+
+  push(frame: LogicalStreamFrame): void {
+    if (this.failed) return
+    this.frames.push(frame)
+    this.wake?.()
+    this.wake = undefined
+  }
+
+  fail(reason: unknown): void {
+    if (this.failed) return
+    this.failed = true
+    this.failure = reason
+    this.frames.length = 0
+    this.wake?.()
+    this.wake = undefined
+  }
+
+  async next(): Promise<LogicalStreamFrame> {
+    while (this.frames.length === 0) {
+      if (this.failed) throw this.failure
+      await new Promise<void>((resolve) => { this.wake = resolve })
+    }
+    return this.frames.shift() as LogicalStreamFrame
+  }
+}
+
 /**
 /**
  * Statuses the worker only produces when the host refused the exchange rather than
  * Statuses the worker only produces when the host refused the exchange rather than
  * answered it; a route's own 4xx is the tree talking and stays silent here.
  * answered it; a route's own 4xx is the tree talking and stays silent here.
@@ -72,8 +109,9 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304])
 export class WorkerTunnel {
 export class WorkerTunnel {
   private readonly worker: Worker
   private readonly worker: Worker
   private nextId = 1
   private nextId = 1
-  private readonly unary = new Map<number, PendingUnary>()
-  private readonly streams = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
+  private readonly unary = new Map<TunnelRequestId, PendingUnary>()
+  private readonly bodyStreams = new Map<TunnelRequestId, ReadableStreamDefaultController<Uint8Array>>()
+  private readonly logicalStreams = new Map<TunnelRequestId, LogicalStreamInbox>()
   /**
   /**
    * In-flight request descriptions, so a refusal names what was refused.
    * In-flight request descriptions, so a refusal names what was refused.
    *
    *
@@ -82,10 +120,10 @@ export class WorkerTunnel {
    * page console but not the frames. Warning here separates the two without
    * page console but not the frames. Warning here separates the two without
    * recording anything on the normal path, where no refusal frame ever arrives.
    * recording anything on the normal path, where no refusal frame ever arrives.
    */
    */
-  private readonly inFlight = new Map<number, string>()
+  private readonly inFlight = new Map<TunnelRequestId, string>()
 
 
   /** Body-phase abort listeners, released when their stream settles. */
   /** Body-phase abort listeners, released when their stream settles. */
-  private readonly releases = new Map<number, () => void>()
+  private readonly releases = new Map<TunnelRequestId, () => void>()
 
 
   /**
   /**
    * Attach to a spawned worker and start consuming response frames.
    * Attach to a spawned worker and start consuming response frames.
@@ -102,8 +140,14 @@ export class WorkerTunnel {
       this.inFlight.clear()
       this.inFlight.clear()
       for (const pending of this.unary.values()) pending.reject(reason)
       for (const pending of this.unary.values()) pending.reject(reason)
       this.unary.clear()
       this.unary.clear()
-      for (const controller of this.streams.values()) controller.error(reason)
-      this.streams.clear()
+      for (const controller of this.bodyStreams.values()) controller.error(reason)
+      this.bodyStreams.clear()
+      const failure = new TunnelLogicalStreamError({
+        kind: 'carrier',
+        message: `web-preview tunnel: worker failed: ${event.message}`,
+      }, { cause: reason })
+      for (const inbox of this.logicalStreams.values()) inbox.fail(failure)
+      this.logicalStreams.clear()
       for (const release of this.releases.values()) release()
       for (const release of this.releases.values()) release()
       this.releases.clear()
       this.releases.clear()
     })
     })
@@ -145,13 +189,60 @@ export class WorkerTunnel {
       const settled = await Promise.race([response, raced.rejected])
       const settled = await Promise.race([response, raced.rejected])
       // A streaming response outlives its head: hand the signal to the body
       // A streaming response outlives its head: hand the signal to the body
       // phase, so a later stop still ends the stream and reaches the worker.
       // phase, so a later stop still ends the stream and reaches the worker.
-      if (this.streams.has(id)) this.observeStreamAbort(id, signal)
+      if (this.bodyStreams.has(id)) this.observeStreamAbort(id, signal)
       return settled
       return settled
     } finally {
     } finally {
       raced.release()
       raced.release()
     }
     }
   }
   }
 
 
+  /**
+   * Open one decoded Gateway Remote stream over the worker-local carrier.
+   * @param endpoint - canonical Gateway Remote endpoint.
+   * @param payload - decoded endpoint payload.
+   * @param signal - logical-stream cancellation.
+   * @returns decoded stream values from the worker Host.
+   */
+  async *open(endpoint: string, payload: unknown, signal: AbortSignal): AsyncGenerator {
+    signal.throwIfAborted()
+    const id = this.nextId++
+    const inbox = new LogicalStreamInbox()
+    let opened = false
+    let terminal = false
+    const onAbort = (): void => { inbox.fail(signal.reason) }
+    signal.addEventListener('abort', onAbort, { once: true })
+    this.logicalStreams.set(id, inbox)
+    this.inFlight.set(id, `STREAM ${endpoint}`)
+    try {
+      const frame: TunnelStreamOpenFrame = { t: 'stream-open', id, endpoint, payload }
+      try {
+        this.worker.postMessage(frame)
+        opened = true
+      } catch (cause) {
+        throw new TunnelLogicalStreamError({
+          kind: 'carrier',
+          message: `web-preview tunnel: failed to open Remote stream ${endpoint}`,
+        }, { cause })
+      }
+      while (true) {
+        const response = await inbox.next()
+        signal.throwIfAborted()
+        if (response.t === 'stream-item') {
+          yield response.value
+          continue
+        }
+        terminal = true
+        if (response.t === 'stream-error') throw new TunnelLogicalStreamError(response.failure)
+        return
+      }
+    } finally {
+      signal.removeEventListener('abort', onAbort)
+      this.logicalStreams.delete(id)
+      this.inFlight.delete(id)
+      if (opened && !terminal) this.abortWorkerOperation(id)
+    }
+  }
+
   /**
   /**
    * Read the pre-cordis boot payload (the injection table).
    * Read the pre-cordis boot payload (the injection table).
    * @returns The payload the page applies before the client tree loads.
    * @returns The payload the page applies before the client tree loads.
@@ -198,7 +289,7 @@ export class WorkerTunnel {
     }
     }
   }
   }
 
 
-  private rejectOnAbort(id: number, signal: AbortSignal): { rejected: Promise<never>; release: () => void } {
+  private rejectOnAbort(id: TunnelRequestId, signal: AbortSignal): { rejected: Promise<never>; release: () => void } {
     let release = (): void => {}
     let release = (): void => {}
     const rejected = new Promise<never>((_resolve, reject) => {
     const rejected = new Promise<never>((_resolve, reject) => {
       const fail = (): void => { reject(this.abortRequest(id)) }
       const fail = (): void => { reject(this.abortRequest(id)) }
@@ -220,14 +311,13 @@ export class WorkerTunnel {
    * @param id - request id being abandoned.
    * @param id - request id being abandoned.
    * @returns The abort error the caller surfaces.
    * @returns The abort error the caller surfaces.
    */
    */
-  private abortRequest(id: number): DOMException {
+  private abortRequest(id: TunnelRequestId): DOMException {
     this.unary.delete(id)
     this.unary.delete(id)
-    const controller = this.streams.get(id)
-    this.streams.delete(id)
+    const controller = this.bodyStreams.get(id)
+    this.bodyStreams.delete(id)
     this.inFlight.delete(id)
     this.inFlight.delete(id)
     this.releases.delete(id)
     this.releases.delete(id)
-    const abort: AbortFrame = { t: 'abort', id }
-    this.worker.postMessage(abort)
+    this.abortWorkerOperation(id)
     const reason = new DOMException('The operation was aborted.', 'AbortError')
     const reason = new DOMException('The operation was aborted.', 'AbortError')
     controller?.error(reason)
     controller?.error(reason)
     return reason
     return reason
@@ -240,26 +330,35 @@ export class WorkerTunnel {
    * @param id - request id whose body is still crossing.
    * @param id - request id whose body is still crossing.
    * @param signal - the caller's signal.
    * @param signal - the caller's signal.
    */
    */
-  private observeStreamAbort(id: number, signal: AbortSignal): void {
+  private observeStreamAbort(id: TunnelRequestId, signal: AbortSignal): void {
     const onAbort = (): void => { this.abortRequest(id) }
     const onAbort = (): void => { this.abortRequest(id) }
     signal.addEventListener('abort', onAbort, { once: true })
     signal.addEventListener('abort', onAbort, { once: true })
     this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) })
     this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) })
   }
   }
 
 
   /** Release a body-phase abort listener a settled stream no longer needs. */
   /** Release a body-phase abort listener a settled stream no longer needs. */
-  private releaseSignal(id: number): void {
+  private releaseSignal(id: TunnelRequestId): void {
     const release = this.releases.get(id)
     const release = this.releases.get(id)
     this.releases.delete(id)
     this.releases.delete(id)
     release?.()
     release?.()
   }
   }
 
 
   /** Cancel a stream the consumer stopped reading (the head already resolved). */
   /** Cancel a stream the consumer stopped reading (the head already resolved). */
-  private cancelStream(id: number): void {
+  private cancelStream(id: TunnelRequestId): void {
     this.releaseSignal(id)
     this.releaseSignal(id)
-    this.streams.delete(id)
+    this.bodyStreams.delete(id)
     this.inFlight.delete(id)
     this.inFlight.delete(id)
+    this.abortWorkerOperation(id)
+  }
+
+  /** Best-effort cancellation: a failed worker cannot receive the frame anyway. */
+  private abortWorkerOperation(id: TunnelRequestId): void {
     const abort: AbortFrame = { t: 'abort', id }
     const abort: AbortFrame = { t: 'abort', id }
-    this.worker.postMessage(abort)
+    try {
+      this.worker.postMessage(abort)
+    } catch {
+      // The operation is already locally terminal; worker failure is reported by its owning path.
+    }
   }
   }
 
 
   /**
   /**
@@ -271,7 +370,7 @@ export class WorkerTunnel {
    * @param id - request id the frame answers.
    * @param id - request id the frame answers.
    * @param outcome - what came back instead of a reply.
    * @param outcome - what came back instead of a reply.
    */
    */
-  private warnRefusal(id: number, outcome: string): void {
+  private warnRefusal(id: TunnelRequestId, outcome: string): void {
     console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'} → ${outcome}`)
     console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'} → ${outcome}`)
   }
   }
 
 
@@ -297,7 +396,7 @@ export class WorkerTunnel {
         this.unary.delete(frame.id)
         this.unary.delete(frame.id)
         const stream = new ReadableStream<Uint8Array>({
         const stream = new ReadableStream<Uint8Array>({
           start: (controller) => {
           start: (controller) => {
-            this.streams.set(frame.id, controller)
+            this.bodyStreams.set(frame.id, controller)
           },
           },
           cancel: () => {
           cancel: () => {
             this.cancelStream(frame.id)
             this.cancelStream(frame.id)
@@ -307,13 +406,13 @@ export class WorkerTunnel {
         return
         return
       }
       }
       case 'res-chunk': {
       case 'res-chunk': {
-        this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
+        this.bodyStreams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
         return
         return
       }
       }
       case 'res-end': {
       case 'res-end': {
-        const controller = this.streams.get(frame.id)
+        const controller = this.bodyStreams.get(frame.id)
         if (controller === undefined) return
         if (controller === undefined) return
-        this.streams.delete(frame.id)
+        this.bodyStreams.delete(frame.id)
         this.inFlight.delete(frame.id)
         this.inFlight.delete(frame.id)
         this.releaseSignal(frame.id)
         this.releaseSignal(frame.id)
         controller.close()
         controller.close()
@@ -329,13 +428,19 @@ export class WorkerTunnel {
           pending.reject(reason)
           pending.reject(reason)
           return
           return
         }
         }
-        const controller = this.streams.get(frame.id)
+        const controller = this.bodyStreams.get(frame.id)
         if (controller === undefined) return
         if (controller === undefined) return
-        this.streams.delete(frame.id)
+        this.bodyStreams.delete(frame.id)
         this.releaseSignal(frame.id)
         this.releaseSignal(frame.id)
         controller.error(reason)
         controller.error(reason)
         return
         return
       }
       }
+      case 'stream-item':
+      case 'stream-end':
+      case 'stream-error': {
+        this.logicalStreams.get(frame.id)?.push(frame)
+        return
+      }
       default: {
       default: {
         const unknown: never = frame
         const unknown: never = frame
         throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`)
         throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`)

+ 2 - 0
packages/experimental/webworker-runtime/src/client/index.ts

@@ -23,6 +23,7 @@ interface ClientTransportGlobal {
   __DSH_TRANSPORT__?: {
   __DSH_TRANSPORT__?: {
     createApiClient: () => WorkerApiClient
     createApiClient: () => WorkerApiClient
     fetch: TunnelFetch
     fetch: TunnelFetch
+    openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>
     loadBundle: (url: string) => Promise<void>
     loadBundle: (url: string) => Promise<void>
     /** The page spawned the worker the Host runs in, so the page owns it. */
     /** The page spawned the worker the Host runs in, so the page owns it. */
     ownsHost: boolean
     ownsHost: boolean
@@ -84,6 +85,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
     ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
     ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
       createApiClient: () => new WorkerApiClient(tunnel),
       createApiClient: () => new WorkerApiClient(tunnel),
       fetch: (input, init) => tunnel.fetch(input, init),
       fetch: (input, init) => tunnel.fetch(input, init),
+      openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal),
       loadBundle: (url: string) => tunnel.loadBundle(url),
       loadBundle: (url: string) => tunnel.loadBundle(url),
       // The host lives in a worker this page spawned: the page owns it, so
       // The host lives in a worker this page spawned: the page owns it, so
       // the privileged surface stays reachable off loopback authorities.
       // the privileged surface stays reachable off loopback authorities.

+ 3 - 1
packages/experimental/webworker-runtime/src/index.ts

@@ -11,6 +11,8 @@ export {
   type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame,
   type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame,
   type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame,
   type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame,
   type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame,
   type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame,
+  type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame,
+  type TunnelStreamOpenFrame,
 } from './transport/frames.ts'
 } from './transport/frames.ts'
 export {
 export {
   DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader,
   DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader,
@@ -23,7 +25,7 @@ export {
 } from './transport/synthetic-http.ts'
 } from './transport/synthetic-http.ts'
 export { lowerModuleSource, type LoweredModule } from './compile/transform.ts'
 export { lowerModuleSource, type LoweredModule } from './compile/transform.ts'
 export {
 export {
-  API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer,
+  API_PREFIX, SYNTHETIC_HOST, TunnelServer,
   type TunnelPort, type TunnelSeams, type TunnelServerOptions,
   type TunnelPort, type TunnelSeams, type TunnelServerOptions,
 } from './transport/tunnel.ts'
 } from './transport/tunnel.ts'
 export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts'
 export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts'

+ 52 - 1
packages/experimental/webworker-runtime/src/transport/frames.ts

@@ -17,6 +17,14 @@ export interface TunnelRequestFrame {
   readonly body?: ArrayBuffer | undefined
   readonly body?: ArrayBuffer | undefined
 }
 }
 
 
+/** Open one Gateway Remote stream over the worker-local carrier. */
+export interface TunnelStreamOpenFrame {
+  readonly t: 'stream-open'
+  readonly id: TunnelRequestId
+  readonly endpoint: string
+  readonly payload: unknown
+}
+
 /** Page-side cancellation of an in-flight request or stream. */
 /** Page-side cancellation of an in-flight request or stream. */
 export interface TunnelAbortFrame {
 export interface TunnelAbortFrame {
   readonly t: 'abort'
   readonly t: 'abort'
@@ -34,7 +42,11 @@ export interface TunnelInitFrame {
 }
 }
 
 
 /** Every frame the page sends the worker. */
 /** Every frame the page sends the worker. */
-export type TunnelInboundFrame = TunnelInitFrame | TunnelRequestFrame | TunnelAbortFrame
+export type TunnelInboundFrame =
+  | TunnelInitFrame
+  | TunnelRequestFrame
+  | TunnelStreamOpenFrame
+  | TunnelAbortFrame
 
 
 /** Complete response for unary requests and static files. */
 /** Complete response for unary requests and static files. */
 export interface TunnelResponseFrame {
 export interface TunnelResponseFrame {
@@ -75,6 +87,36 @@ export interface TunnelResponseErrorFrame {
   readonly message: string
   readonly message: string
 }
 }
 
 
+/** One decoded value from a worker-local Gateway Remote stream. */
+export interface TunnelStreamItemFrame {
+  readonly t: 'stream-item'
+  readonly id: TunnelRequestId
+  readonly value?: unknown
+}
+
+/** Normal completion of a worker-local Gateway Remote stream. */
+export interface TunnelStreamEndFrame {
+  readonly t: 'stream-end'
+  readonly id: TunnelRequestId
+}
+
+/** Stable Host failure or worker-carrier failure for one logical stream. */
+export interface TunnelStreamErrorFrame {
+  readonly t: 'stream-error'
+  readonly id: TunnelRequestId
+  readonly failure:
+    | {
+      readonly kind: 'remote'
+      readonly code: string
+      readonly message: string
+      readonly details: object
+    }
+    | {
+      readonly kind: 'carrier'
+      readonly message: string
+    }
+}
+
 /** Frames the worker emits. */
 /** Frames the worker emits. */
 export type TunnelOutboundFrame =
 export type TunnelOutboundFrame =
   | TunnelResponseFrame
   | TunnelResponseFrame
@@ -82,6 +124,9 @@ export type TunnelOutboundFrame =
   | TunnelResponseChunkFrame
   | TunnelResponseChunkFrame
   | TunnelResponseEndFrame
   | TunnelResponseEndFrame
   | TunnelResponseErrorFrame
   | TunnelResponseErrorFrame
+  | TunnelStreamItemFrame
+  | TunnelStreamEndFrame
+  | TunnelStreamErrorFrame
 
 
 /**
 /**
  * Validate a `postMessage` payload as a tunnel frame.
  * Validate a `postMessage` payload as a tunnel frame.
@@ -104,6 +149,12 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame {
     throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`)
     throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`)
   }
   }
   if (frame.t === 'abort') return { t: 'abort', id }
   if (frame.t === 'abort') return { t: 'abort', id }
+  if (frame.t === 'stream-open') {
+    if (typeof frame.endpoint !== 'string' || frame.endpoint.length === 0) {
+      throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`)
+    }
+    return { t: 'stream-open', id, endpoint: frame.endpoint, payload: frame.payload }
+  }
   if (frame.t !== 'req') throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`)
   if (frame.t !== 'req') throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`)
   if (typeof frame.method !== 'string' || typeof frame.url !== 'string') {
   if (typeof frame.method !== 'string' || typeof frame.url !== 'string') {
     throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`)
     throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`)

+ 63 - 12
packages/experimental/webworker-runtime/src/transport/tunnel.ts

@@ -4,8 +4,6 @@
  *
  *
  * - `GET /__boot__` answers from tunnel glue, never from the host API surface,
  * - `GET /__boot__` answers from tunnel glue, never from the host API surface,
  *   because the page needs the boot payload before its Cordis tree exists.
  *   because the page needs the boot payload before its Cordis tree exists.
- * - The two event-stream paths go straight to the API fetch handler so they take
- *   its SSE branch; the `/api` route answers 426 upgrade-required first.
  * - Privileged `/api` methods take that same direct entry: the browser strips the
  * - Privileged `/api` methods take that same direct entry: the browser strips the
  *   `host` header from the WHATWG `Request` the route lane rebuilds, so the
  *   `host` header from the WHATWG `Request` the route lane rebuilds, so the
  *   privileged fence would answer 403 for every one of them. The method set is
  *   privileged fence would answer 403 for every one of them. The method set is
@@ -21,14 +19,12 @@
  */
  */
 import {
 import {
   parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId,
   parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId,
+  type TunnelStreamOpenFrame,
 } from './frames.ts'
 } from './frames.ts'
 import {
 import {
   createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange,
   createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange,
 } from './synthetic-http.ts'
 } from './synthetic-http.ts'
 
 
-/** Event-stream routes that must reach the SSE branch of the API fetch handler. */
-export const STREAM_PATHS = ['/api/events.mux', '/api/events.host'] as const
-
 /** Prefix owning the API methods. */
 /** Prefix owning the API methods. */
 export const API_PREFIX = '/api'
 export const API_PREFIX = '/api'
 
 
@@ -91,12 +87,24 @@ export interface TunnelPort {
 /** What the tunnel gains once the host tree is up. */
 /** What the tunnel gains once the host tree is up. */
 export interface TunnelSeams {
 export interface TunnelSeams {
   /**
   /**
-   * Direct entry to the API fetch handler: event streams, privileged methods,
-   * and any unary call the route lane refused with 403.
+   * Direct entry to the API fetch handler for privileged methods and any unary
+   * call the route lane refused with 403.
    */
    */
   readonly directFetch: (request: Request) => Promise<Response>
   readonly directFetch: (request: Request) => Promise<Response>
   /** Boot payload for `GET /__boot__`: the structured index injection table. */
   /** Boot payload for `GET /__boot__`: the structured index injection table. */
   readonly bootPayload: () => unknown
   readonly bootPayload: () => unknown
+  /** Open one decoded Gateway Remote stream without another network carrier. */
+  readonly openStream: (
+    endpoint: string,
+    payload: unknown,
+    signal: AbortSignal,
+  ) => Promise<AsyncIterable<unknown>>
+  /** Convert a Gateway stream failure to stable Client fields. */
+  readonly streamFailure: (error: unknown) => {
+    readonly code: string
+    readonly message: string
+    readonly details: object
+  }
 }
 }
 
 
 /** Construction inputs for {@link TunnelServer}. */
 /** Construction inputs for {@link TunnelServer}. */
@@ -125,6 +133,8 @@ interface InFlight {
   abort(): void
   abort(): void
 }
 }
 
 
+type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame
+
 /** Recorded response frames, so a 403 from the route lane can be discarded. */
 /** Recorded response frames, so a 403 from the route lane can be discarded. */
 class BufferedSink {
 class BufferedSink {
   private readonly calls: Array<() => void> = []
   private readonly calls: Array<() => void> = []
@@ -171,7 +181,7 @@ export class TunnelServer {
   private readonly requestListener: () => Promise<RequestListener>
   private readonly requestListener: () => Promise<RequestListener>
   private readonly privilegedMethods: ReadonlySet<string> | undefined
   private readonly privilegedMethods: ReadonlySet<string> | undefined
   private readonly unaryApiLane: 'route' | 'direct'
   private readonly unaryApiLane: 'route' | 'direct'
-  private readonly queue: TunnelRequestFrame[] = []
+  private readonly queue: QueuedFrame[] = []
   private readonly inFlight = new Map<TunnelRequestId, InFlight>()
   private readonly inFlight = new Map<TunnelRequestId, InFlight>()
   private seams: TunnelSeams | undefined
   private seams: TunnelSeams | undefined
   private failure: string | undefined
   private failure: string | undefined
@@ -209,7 +219,7 @@ export class TunnelServer {
       this.queue.push(frame)
       this.queue.push(frame)
       return
       return
     }
     }
-    void this.serveRequest(frame)
+    this.dispatchFrame(frame)
   }
   }
 
 
   /**
   /**
@@ -219,7 +229,7 @@ export class TunnelServer {
   serve(seams: TunnelSeams): void {
   serve(seams: TunnelSeams): void {
     this.seams = seams
     this.seams = seams
     console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`)
     console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`)
-    for (const frame of this.queue.splice(0)) void this.serveRequest(frame)
+    for (const frame of this.queue.splice(0)) this.dispatchFrame(frame)
   }
   }
 
 
   /**
   /**
@@ -237,7 +247,15 @@ export class TunnelServer {
     this.port.postMessage(frame, transfer)
     this.port.postMessage(frame, transfer)
   }
   }
 
 
-  private refuse(frame: TunnelRequestFrame, message: string): void {
+  private refuse(frame: QueuedFrame, message: string): void {
+    if (frame.t === 'stream-open') {
+      this.send({
+        t: 'stream-error',
+        id: frame.id,
+        failure: { kind: 'carrier', message },
+      })
+      return
+    }
     const body = toTransferable(encoder.encode(message))
     const body = toTransferable(encoder.encode(message))
     this.send({
     this.send({
       t: 'res',
       t: 'res',
@@ -249,6 +267,40 @@ export class TunnelServer {
     }, [body])
     }, [body])
   }
   }
 
 
+  private dispatchFrame(frame: QueuedFrame): void {
+    if (frame.t === 'stream-open') void this.serveStream(frame)
+    else void this.serveRequest(frame)
+  }
+
+  private async serveStream(frame: TunnelStreamOpenFrame): Promise<void> {
+    if (this.seams === undefined) {
+      this.refuse(frame, 'webworker tunnel: Remote stream requested before the host tree is serving')
+      return
+    }
+    const seams = this.seams
+    const controller = new AbortController()
+    this.inFlight.set(frame.id, { abort: () => { controller.abort() } })
+    try {
+      const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal)
+      for await (const value of source) {
+        if (controller.signal.aborted) return
+        this.send({ t: 'stream-item', id: frame.id, value })
+      }
+      if (!controller.signal.aborted) this.send({ t: 'stream-end', id: frame.id })
+    } catch (error) {
+      if (!controller.signal.aborted) {
+        const failure = seams.streamFailure(error)
+        this.send({
+          t: 'stream-error',
+          id: frame.id,
+          failure: { kind: 'remote', ...failure },
+        })
+      }
+    } finally {
+      this.inFlight.delete(frame.id)
+    }
+  }
+
   private sinkFor(id: TunnelRequestId): ResponseSink {
   private sinkFor(id: TunnelRequestId): ResponseSink {
     const send = this.send.bind(this)
     const send = this.send.bind(this)
     const inFlight = this.inFlight
     const inFlight = this.inFlight
@@ -291,7 +343,6 @@ export class TunnelServer {
     try {
     try {
       const { frame: routed, path } = this.pathFrame(frame)
       const { frame: routed, path } = this.pathFrame(frame)
       if (path === '/__boot__') {  this.serveBoot(frame, sink); return }
       if (path === '/__boot__') {  this.serveBoot(frame, sink); return }
-      if ((STREAM_PATHS as readonly string[]).includes(path)) {  await this.serveDirect(frame, sink); return }
       if (path.startsWith(`${API_PREFIX}/`)) {  await this.serveApi(frame, routed, path, sink); return }
       if (path.startsWith(`${API_PREFIX}/`)) {  await this.serveApi(frame, routed, path, sink); return }
       this.dispatch(routed, sink)
       this.dispatch(routed, sink)
     } catch (reason) {
     } catch (reason) {

+ 7 - 0
packages/experimental/webworker-runtime/src/worker-host.ts

@@ -22,6 +22,7 @@
  * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host
  * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host
  */
  */
 import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
 import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
+import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
 import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
 import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
 import { dirname, join } from './module-system/posix-path.ts'
 import { dirname, join } from './module-system/posix-path.ts'
 import { installProcessGlobal } from './node/globals/process.ts'
 import { installProcessGlobal } from './node/globals/process.ts'
@@ -237,6 +238,10 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
 
 
       const apiProxy = ctx.get('apiProxy')
       const apiProxy = ctx.get('apiProxy')
       if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
       if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
+      const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined
+      if (typertGateway === undefined) {
+        throw new Error('webworker host: the tree activated without a typertGateway service')
+      }
       const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as {
       const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as {
         toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
         toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
       }
       }
@@ -248,6 +253,8 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
       tunnel.serve({
       tunnel.serve({
         directFetch: (request: Request) => handler.fetch(request),
         directFetch: (request: Request) => handler.fetch(request),
         bootPayload: () => readBootPayload(ctx),
         bootPayload: () => readBootPayload(ctx),
+        openStream: typertGateway.wireStream.open,
+        streamFailure: typertGateway.wireStream.failure,
       })
       })
     } catch (reason) {
     } catch (reason) {
       tunnel.fail(reason)
       tunnel.fail(reason)

+ 106 - 5
packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts

@@ -27,17 +27,31 @@ const warnings: string[] = []
 console.warn = (message: string) => { warnings.push(message) }
 console.warn = (message: string) => { warnings.push(message) }
 ;(globalThis as { location?: unknown }).location = { origin: 'http://localhost:4173' }
 ;(globalThis as { location?: unknown }).location = { origin: 'http://localhost:4173' }
 
 
-type Listener = (event: { data: unknown }) => void
+type StubListener = (event: { data?: unknown; message?: string }) => void
 
 
 /** A worker stand-in: collects what the page sent, replays what the test delivers. */
 /** A worker stand-in: collects what the page sent, replays what the test delivers. */
-function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deliver: (frame: unknown) => void } {
-  const listeners: Listener[] = []
+function stubWorker(): {
+  worker: Worker
+  sent: { t: string; id: number }[]
+  deliver: (frame: unknown) => void
+  fail: (message: string) => void
+} {
+  const listeners: StubListener[] = []
+  const errorListeners: StubListener[] = []
   const sent: { t: string; id: number }[] = []
   const sent: { t: string; id: number }[] = []
   const worker = {
   const worker = {
-    addEventListener: (type: string, listener: Listener) => { if (type === 'message') listeners.push(listener) },
+    addEventListener: (type: string, listener: StubListener) => {
+      if (type === 'message') listeners.push(listener)
+      if (type === 'error') errorListeners.push(listener)
+    },
     postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) },
     postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) },
   } as unknown as Worker
   } as unknown as Worker
-  return { worker, sent, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) } }
+  return {
+    worker,
+    sent,
+    deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) },
+    fail: (message) => { for (const listener of errorListeners) listener({ message }) },
+  }
 }
 }
 
 
 // A normal reply resolves and says nothing on the console.
 // A normal reply resolves and says nothing on the console.
@@ -129,3 +143,90 @@ function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deli
   // A late reply to an aborted request must not resurrect it.
   // A late reply to an aborted request must not resurrect it.
   deliver({ t: 'res', id: 1, status: 200, headers: {}, message: 'late' })
   deliver({ t: 'res', id: 1, status: 200, headers: {}, message: 'late' })
 }
 }
+
+// A logical Gateway stream carries decoded values and one terminal frame.
+{
+  const { worker, sent, deliver } = stubWorker()
+  const tunnel = new WorkerTunnel(worker)
+  const signal = new AbortController()
+  const stream = tunnel.open('session/follow', { args: { sessionId: 'session-1' } }, signal.signal)
+    [Symbol.asyncIterator]()
+  const first = stream.next()
+  check('a logical stream opens on the worker-local carrier', sent[0], {
+    t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
+  })
+  deliver({ t: 'stream-item', id: 1, value: { type: 'baseline' } })
+  check('a logical stream yields decoded values', await first, { value: { type: 'baseline' }, done: false })
+  const ended = stream.next()
+  deliver({ t: 'stream-end', id: 1 })
+  check('a logical stream closes normally', await ended, { done: true, value: undefined })
+  check('normal stream completion sends no cancellation', sent, [
+    { t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } } },
+  ])
+}
+
+// Host failures retain their code and details for the Gateway Client bundle to normalize.
+{
+  const { worker, deliver } = stubWorker()
+  const tunnel = new WorkerTunnel(worker)
+  const pending = tunnel.open('session/follow', {}, new AbortController().signal).next()
+  deliver({
+    t: 'stream-error',
+    id: 1,
+    failure: {
+      kind: 'remote',
+      code: 'session-not-found',
+      message: 'fixture Session is absent',
+      details: { sessionId: 'session-1' },
+    },
+  })
+  const failure = await pending.then(() => undefined, (error: unknown) => error as {
+    message: string
+    dshRemoteStreamFailure: unknown
+  })
+  check('a logical Host failure retains its structural marker', {
+    message: failure?.message,
+    dshRemoteStreamFailure: failure?.dshRemoteStreamFailure,
+  }, {
+    message: 'fixture Session is absent',
+    dshRemoteStreamFailure: {
+      kind: 'remote', code: 'session-not-found', details: { sessionId: 'session-1' },
+    },
+  })
+}
+
+// Caller cancellation keeps the caller's exact reason and reaches the worker once.
+{
+  const { worker, sent, deliver } = stubWorker()
+  const tunnel = new WorkerTunnel(worker)
+  const abort = new AbortController()
+  const pending = tunnel.open('workspace/follow', {}, abort.signal).next()
+  const reason = new Error('caller stopped the Workspace feed')
+  abort.abort(reason)
+  deliver({ t: 'stream-item', id: 1, value: 'late' })
+  check('logical stream cancellation preserves the caller reason', await pending.then(
+    () => 'resolved',
+    (error: unknown) => error === reason ? 'same reason' : 'different reason',
+  ), 'same reason')
+  check('logical stream cancellation reaches the worker', sent.at(-1), { t: 'abort', id: 1 })
+}
+
+// A failed worker is a carrier failure, not a fabricated Host Remote error.
+{
+  warnings.length = 0
+  const { worker, fail } = stubWorker()
+  const tunnel = new WorkerTunnel(worker)
+  const pending = tunnel.open('$events', { args: {} }, new AbortController().signal).next()
+  fail('worker crashed')
+  const failure = await pending.then(() => undefined, (error: unknown) => error as {
+    message: string
+    dshRemoteStreamFailure: unknown
+  })
+  check('worker failure carries the carrier marker', {
+    message: failure?.message,
+    dshRemoteStreamFailure: failure?.dshRemoteStreamFailure,
+  }, {
+    message: 'web-preview tunnel: worker failed: worker crashed',
+    dshRemoteStreamFailure: { kind: 'carrier' },
+  })
+}

+ 113 - 0
packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts

@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { TunnelOutboundFrame } from '../../src/transport/frames.ts'
+import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts'
+
+function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } {
+  const frames: TunnelOutboundFrame[] = []
+  const server = new TunnelServer({
+    port: { postMessage: (frame) => { frames.push(frame) } },
+    requestListener: () => Promise.reject(new Error('fixture has no HTTP listener')),
+  })
+  return { server, frames }
+}
+
+function seams(openStream: TunnelSeams['openStream']): TunnelSeams {
+  return {
+    directFetch: () => Promise.reject(new Error('fixture has no direct fetch')),
+    bootPayload: () => ({}),
+    openStream,
+    streamFailure: error => ({
+      code: 'fixture-stream-failed',
+      message: error instanceof Error ? error.message : String(error),
+      details: { fixture: true },
+    }),
+  }
+}
+
+describe('worker tunnel logical streams', () => {
+  it('drains a pre-boot open through the worker-local Gateway seam', async () => {
+    const { server, frames } = harness()
+    const seen: unknown[] = []
+    server.handleMessage({
+      t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
+    })
+    expect(frames).toEqual([])
+
+    server.serve(seams(async (endpoint, payload, signal) => {
+      seen.push(endpoint, payload, signal)
+      return (async function *(): AsyncGenerator {
+        yield { type: 'baseline' }
+        yield { type: 'event', seq: 1 }
+      })()
+    }))
+
+    await vi.waitFor(() => {
+      expect(frames).toEqual([
+        { t: 'stream-item', id: 1, value: { type: 'baseline' } },
+        { t: 'stream-item', id: 1, value: { type: 'event', seq: 1 } },
+        { t: 'stream-end', id: 1 },
+      ])
+    })
+    expect(seen).toEqual([
+      'session/follow',
+      { args: { sessionId: 'session-1' } },
+      expect.any(AbortSignal),
+    ])
+  })
+
+  it('cancels one logical stream without emitting a terminal frame', async () => {
+    const { server, frames } = harness()
+    const opened = Promise.withResolvers<AbortSignal>()
+    const stopped = Promise.withResolvers<undefined>()
+    server.serve(seams(async (_endpoint, _payload, signal) => {
+      opened.resolve(signal)
+      return (async function *(): AsyncGenerator {
+        yield 'ready'
+        await new Promise<void>((resolve) => {
+          signal.addEventListener('abort', () => { resolve() }, { once: true })
+        })
+        stopped.resolve(undefined)
+      })()
+    }))
+    server.handleMessage({ t: 'stream-open', id: 2, endpoint: '$events', payload: { args: {} } })
+    const signal = await opened.promise
+    await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-item', id: 2, value: 'ready' }) })
+
+    server.handleMessage({ t: 'abort', id: 2 })
+    await stopped.promise
+    expect(signal.aborted).toBe(true)
+    expect(frames).toEqual([{ t: 'stream-item', id: 2, value: 'ready' }])
+  })
+
+  it('maps a Host stream failure through Gateway-owned fields', async () => {
+    const { server, frames } = harness()
+    server.serve(seams(async () => { throw new Error('Host stream exploded') }))
+    server.handleMessage({ t: 'stream-open', id: 3, endpoint: 'probe/watch', payload: {} })
+
+    await vi.waitFor(() => {
+      expect(frames).toEqual([{
+        t: 'stream-error',
+        id: 3,
+        failure: {
+          kind: 'remote',
+          code: 'fixture-stream-failed',
+          message: 'Host stream exploded',
+          details: { fixture: true },
+        },
+      }])
+    })
+  })
+
+  it('refuses queued and future streams after boot failure as carrier failures', () => {
+    const { server, frames } = harness()
+    server.handleMessage({ t: 'stream-open', id: 4, endpoint: '$events', payload: {} })
+    server.fail(new Error('image failed'))
+    server.handleMessage({ t: 'stream-open', id: 5, endpoint: '$events', payload: {} })
+
+    expect(frames).toEqual([4, 5].map(id => ({
+      t: 'stream-error',
+      id,
+      failure: { kind: 'carrier', message: 'Error: image failed' },
+    })))
+  })
+})

+ 2 - 37
packages/interaction/user-approval/src/index.ts

@@ -10,7 +10,6 @@ import z from '@deepseek-ai/schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
 import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
-import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 
@@ -18,44 +17,10 @@ declare module '@deepseek-ai/cordis' {
   interface Context {
   interface Context {
     approval: ApprovalService
     approval: ApprovalService
   }
   }
-
-  interface Events {
-    /**
-     * Ask composed answerers for one decision. Return an outcome to claim the
-     * request or call `next()`; failure yields the fail-closed default.
-     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-     * @param req - the pending decision (agent, tool identity, reason, signal).
-     * @mode waterfall
-     */
-    'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
-  }
 }
 }
 
 
 declare module '@deepseek-ai/dsh-session/types' {
 declare module '@deepseek-ai/dsh-session/types' {
   interface SessionEventMap {
   interface SessionEventMap {
-    /**
-     * An approval question was put to the answerer chain — log-only audit
-     * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
-     * it with the `approval/decided` that always follows; `toolName` is the
-     * tool the question is about, `callId` the exact tool call when the asker
-     * had one, `reason` the asker's human-readable explanation (e.g. a hook's
-     * permission-decision reason).
-     */
-    'approval/asked': {
-      id: ApprovalRequestId
-      toolName: string
-      callId?: CallId
-      reason?: string
-    }
-    /**
-     * The outcome of a prior `approval/asked` (same `id`) — log-only audit.
-     * Exactly one per ask, appended when the outcome is known: a decision, a
-     * cancellation, or the fail-closed `'unavailable'`.
-     */
-    'approval/decided': {
-      id: ApprovalRequestId
-      outcome: ApprovalOutcome
-    }
     /**
     /**
      * The session's approval policy was switched — log-only, durable,
      * The session's approval policy was switched — log-only, durable,
      * replayable, never in the model transcript (the model learns the policy
      * replayable, never in the model transcript (the model learns the policy
@@ -73,7 +38,7 @@ declare module '@deepseek-ai/dsh-session/types' {
 }
 }
 
 
 import { ApprovalRequestId } from './types.ts'
 import { ApprovalRequestId } from './types.ts'
-import type { ApprovalOutcome } from './types.ts'
+import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts'
 
 
 export { ApprovalRequestId } from './types.ts'
 export { ApprovalRequestId } from './types.ts'
 export type { ApprovalOutcome } from './types.ts'
 export type { ApprovalOutcome } from './types.ts'
@@ -150,7 +115,7 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
  * Readonly same-process permission question. `callId` links to an already
  * Readonly same-process permission question. `callId` links to an already
  * presented tool call, so arguments are not duplicated here.
  * presented tool call, so arguments are not duplicated here.
  */
  */
-export interface ApprovalRequest {
+export interface ApprovalRequest extends ApprovalRequestEvent {
   /**
   /**
    * The agent on whose behalf the question is asked. Routes the question (a
    * The agent on whose behalf the question is asked. Routes the question (a
    * UI answerer only answers for agents it owns) and receives the audit
    * UI answerer only answers for agents it owns) and receives the audit

+ 61 - 0
packages/interaction/user-approval/src/types.ts

@@ -6,6 +6,9 @@
  */
  */
 
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { Scoped } from '@deepseek-ai/dsh-scope'
+import type { Agent } from '@deepseek-ai/dsh-agent/types'
+import type { CallId } from '@deepseek-ai/dsh-llm/brand'
 
 
 /**
 /**
  * Pairs one `approval/asked` audit event with its `approval/decided`.
  * Pairs one `approval/asked` audit event with its `approval/decided`.
@@ -27,3 +30,61 @@ export function ApprovalRequestId(id: string): ApprovalRequestId {
  * request, or unavailable answerer. Callers fail closed on `unavailable`.
  * request, or unavailable answerer. Callers fail closed on `unavailable`.
  */
  */
 export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
 export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /**
+     * An approval question was put to the answerer chain — log-only audit
+     * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
+     * it with the `approval/decided` that always follows; `toolName` is the
+     * tool the question is about, `callId` the exact tool call when the asker
+     * had one, `reason` the asker's human-readable explanation (e.g. a hook's
+     * permission-decision reason).
+     */
+    'approval/asked': {
+      id: ApprovalRequestId
+      toolName: string
+      callId?: CallId
+      reason?: string
+    }
+    /**
+     * The outcome of a prior `approval/asked` (same `id`) — log-only audit.
+     * Exactly one per ask, appended when the outcome is known: a decision, a
+     * cancellation, or the fail-closed `'unavailable'`.
+     */
+    'approval/decided': {
+      id: ApprovalRequestId
+      outcome: ApprovalOutcome
+    }
+  }
+}
+
+/** Client-safe payload declared for the approval answerer waterfall. */
+export interface ApprovalRequestEvent {
+  /** Agent identity projected to the corresponding Client Context in transit. */
+  readonly agent: Agent
+  /** Tool whose operation requires a decision. */
+  readonly toolName: string
+  /** Exact tool call being decided, when available. */
+  readonly callId?: CallId
+  /** Human-readable reason supplied by the asker. */
+  readonly reason?: string
+  /** Cancellation lifetime of the pending request. */
+  readonly signal?: AbortSignal
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * Ask composed answerers for one decision. Return an outcome to claim the
+     * request or call `next()` to delegate.
+     * @param req - pending approval request.
+     * @mode waterfall
+     */
+    'approval/request'(
+      this: Scoped<object>,
+      req: ApprovalRequestEvent,
+      next: () => Promise<ApprovalOutcome>,
+    ): Promise<ApprovalOutcome>
+  }
+}

+ 4 - 2
packages/interaction/user-questions/src/index.ts

@@ -17,7 +17,9 @@ declare module '@deepseek-ai/cordis' {
   }
   }
 }
 }
 
 
-import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
+import type {
+  AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequestEvent,
+} from './types.ts'
 
 
 export type {
 export type {
   AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
   AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
@@ -25,7 +27,7 @@ export type {
 } from './types.ts'
 } from './types.ts'
 
 
 /** Request for a human answer. */
 /** Request for a human answer. */
-export interface AskUserQuestionRequest {
+export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {
   /** Questions to display. */
   /** Questions to display. */
   questions: AskUserQuestionItem[]
   questions: AskUserQuestionItem[]
   /** Exact live calling agent, when the request came from an agent tool call. */
   /** Exact live calling agent, when the request came from an agent tool call. */

+ 30 - 6
packages/interaction/user-questions/src/types.ts

@@ -1,9 +1,7 @@
-/**
- * Wire-safe question and answer types, free of cordis/service imports so browser
- * type chains (apiproxy api → client) can consume them without loading this
- * package's Context augmentation.
- * @module @deepseek-ai/dsh-user-questions/types
- */
+/** Client-safe question, answer, and event types. @module @deepseek-ai/dsh-user-questions/types */
+
+import type { Scoped } from '@deepseek-ai/dsh-scope'
+import type { Agent } from '@deepseek-ai/dsh-agent/types'
 
 
 /** One selectable answer offered to the user. */
 /** One selectable answer offered to the user. */
 export interface AskUserQuestionOption {
 export interface AskUserQuestionOption {
@@ -64,3 +62,29 @@ export interface AskUserQuestionAnswer {
   /** Structured answers keyed by question id. */
   /** Structured answers keyed by question id. */
   answers: AskUserQuestionAnswerItem[]
   answers: AskUserQuestionAnswerItem[]
 }
 }
+
+/** Client-safe payload declared for the user-question answerer waterfall. */
+export interface AskUserQuestionRequestEvent {
+  /** Questions to display. */
+  questions: AskUserQuestionItem[]
+  /** Agent identity projected to the corresponding Client Context in transit. */
+  agent?: Agent
+  /** Cancellation lifetime of the pending request. */
+  signal?: AbortSignal
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * Ask composed answerers for structured user input. Return an answer to
+     * claim the request or call `next()` to delegate.
+     * @param request - pending user-question request.
+     * @mode waterfall
+     */
+    'user-questions/request'(
+      this: Scoped<object>,
+      request: AskUserQuestionRequestEvent,
+      next: () => Promise<AskUserQuestionAnswer>,
+    ): Promise<AskUserQuestionAnswer>
+  }
+}

+ 47 - 18
packages/typert/generator/src/analyzer.ts

@@ -976,7 +976,7 @@ class FaceAnalyzer {
     binding: GatewayBinding,
     binding: GatewayBinding,
     method: ts.MethodDeclaration,
     method: ts.MethodDeclaration,
     invocation:
     invocation:
-      | { readonly kind: 'direct'; readonly exportName?: string }
+      | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' }
       | { readonly kind: 'context'; readonly context: string; readonly exportName?: string },
       | { readonly kind: 'context'; readonly context: string; readonly exportName?: string },
   ): InvocationModel {
   ): InvocationModel {
     if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) {
     if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) {
@@ -1106,13 +1106,15 @@ class FaceAnalyzer {
       }
       }
     }
     }
 
 
-    const resultType = this.remoteResultType(method)
+    const mode = invocation.kind === 'direct' ? invocation.mode : undefined
+    const resultType = this.remoteResultType(method, mode)
     return {
     return {
       id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
       id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
       service: binding.service,
       service: binding.service,
       namespace: binding.namespace,
       namespace: binding.namespace,
       method: exportedMethod,
       method: exportedMethod,
       ...(exportedMethod === methodName ? {} : { implementation: methodName }),
       ...(exportedMethod === methodName ? {} : { implementation: methodName }),
+      ...(mode === undefined ? {} : { mode }),
       invocation: receiver,
       invocation: receiver,
       ...(scope === undefined ? {} : { scope }),
       ...(scope === undefined ? {} : { scope }),
       parameters,
       parameters,
@@ -1215,11 +1217,11 @@ class FaceAnalyzer {
   private remoteMarker(
   private remoteMarker(
     member: ts.ClassElement,
     member: ts.ClassElement,
   ):
   ):
-    | { readonly kind: 'direct'; readonly exportName?: string }
+    | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' }
     | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
     | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
     | undefined {
     | undefined {
     let found:
     let found:
-      | { readonly kind: 'direct'; readonly exportName?: string }
+      | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' }
       | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
       | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
       | undefined
       | undefined
     for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
     for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
@@ -1229,12 +1231,28 @@ class FaceAnalyzer {
         marker = { kind: 'direct' }
         marker = { kind: 'direct' }
       } else if (ts.isCallExpression(expression)
       } else if (ts.isCallExpression(expression)
         && this.isTypeMetaSymbol(expression.expression, 'Remote')) {
         && this.isTypeMetaSymbol(expression.expression, 'Remote')) {
-        if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
-        const exportName = stringLiteralValue(expression.arguments[0])
-        if (exportName === undefined || !isRemoteSegment(exportName)) {
-          this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters')
+        if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one name or options object')
+        const argument = expression.arguments[0]
+        if (argument === undefined) this.fail(expression, 'Remote() requires one name or options object')
+        const exportName = stringLiteralValue(argument)
+        if (exportName !== undefined) {
+          if (!isRemoteSegment(exportName)) {
+            this.fail(argument, 'Remote() name must contain only RPC endpoint segment characters')
+          }
+          marker = { kind: 'direct', exportName }
+        } else {
+          if (!ts.isObjectLiteralExpression(argument) || argument.properties.length !== 1) {
+            this.fail(argument, 'Remote() options must contain exactly mode: "stream"')
+          }
+          const [property] = argument.properties
+          if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"')
+          if (!ts.isPropertyAssignment(property)
+            || memberName(property.name) !== 'mode'
+            || stringLiteralValue(property.initializer) !== 'stream') {
+            this.fail(property, 'Remote() options must contain exactly mode: "stream"')
+          }
+          marker = { kind: 'direct', mode: 'stream' }
         }
         }
-        marker = { kind: 'direct', exportName }
       } else if (ts.isCallExpression(expression)
       } else if (ts.isCallExpression(expression)
         && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) {
         && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) {
         if (expression.arguments.length < 1 || expression.arguments.length > 2) {
         if (expression.arguments.length < 1 || expression.arguments.length > 2) {
@@ -1259,16 +1277,27 @@ class FaceAnalyzer {
     return found
     return found
   }
   }
 
 
-  private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode {
+  private remoteResultType(method: ts.MethodDeclaration, mode?: 'stream'): ts.TypeNode {
     const authored = this.requiredType(method, method.type, 'return')
     const authored = this.requiredType(method, method.type, 'return')
-    if (!ts.isTypeReferenceNode(authored)) return authored
-    const symbol = this.checker.getSymbolAtLocation(authored.typeName)
-    const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
-    const resultType = authored.typeArguments?.[0]
-    if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored
-    const declaration = preferredDeclaration(resolved)
-    if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored
-    return resultType
+    if (ts.isTypeReferenceNode(authored)) {
+      const symbol = this.checker.getSymbolAtLocation(authored.typeName)
+      const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
+      const resultType = authored.typeArguments?.[0]
+      const wrappers = mode === 'stream' ? ['Iterable', 'AsyncIterable'] : ['Promise']
+      const declaration = resolved === undefined ? undefined : preferredDeclaration(resolved)
+      if (resolved !== undefined
+        && wrappers.includes(resolved.name)
+        && resultType !== undefined
+        && authored.typeArguments?.length === 1
+        && declaration !== undefined
+        && isStandardLibraryFile(declaration.getSourceFile().fileName)) {
+        return resultType
+      }
+    }
+    if (mode === 'stream') {
+      this.fail(method, 'stream Remote methods must return Iterable<T> or AsyncIterable<T>')
+    }
+    return authored
   }
   }
 
 
   private isGlobalAbortSignal(type: ts.TypeNode): boolean {
   private isGlobalAbortSignal(type: ts.TypeNode): boolean {

+ 6 - 3
packages/typert/generator/src/emitter.ts

@@ -282,6 +282,7 @@ export class FaceModelEmitter {
     if (invocation.implementation !== undefined) {
     if (invocation.implementation !== undefined) {
       lines.push(`  implementation: ${quote(invocation.implementation)},`)
       lines.push(`  implementation: ${quote(invocation.implementation)},`)
     }
     }
+    if (invocation.mode !== undefined) lines.push(`  mode: ${quote(invocation.mode)},`)
     if (invocation.invocation.kind === 'direct') {
     if (invocation.invocation.kind === 'direct') {
       lines.push('  invocation: { kind: \'direct\' },')
       lines.push('  invocation: { kind: \'direct\' },')
     } else {
     } else {
@@ -467,9 +468,11 @@ export class FaceModelEmitter {
       `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
       `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
     if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
     if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
     const result = this.renderer.renderType(invocation.result.type, referenceNames)
     const result = this.renderer.renderType(invocation.result.type, referenceNames)
-    // The Client Remote face delivers the carrier's outcome, so every generated
-    // consumer signature resolves to a result the caller reads instead of a
-    // value it must guard with its own try/catch.
+    if (invocation.mode === 'stream') {
+      return `(${parameters.join(', ')}) => AsyncIterable<${result}>`
+    }
+    // The unary Client Remote face delivers the carrier's outcome, so every
+    // generated consumer signature resolves to a result the caller reads.
     return `(${parameters.join(', ')}) => Promise<RemoteResult<${result}>>`
     return `(${parameters.join(', ')}) => Promise<RemoteResult<${result}>>`
   }
   }
 }
 }

+ 1 - 0
packages/typert/generator/src/model.ts

@@ -131,6 +131,7 @@ export interface InvocationModel {
   readonly namespace: string
   readonly namespace: string
   readonly method: string
   readonly method: string
   readonly implementation?: string
   readonly implementation?: string
+  readonly mode?: 'stream'
   readonly invocation:
   readonly invocation:
     | { readonly kind: 'direct' }
     | { readonly kind: 'direct' }
     | {
     | {

+ 6 - 0
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts

@@ -23,6 +23,12 @@ export class GoalService extends TypertRemoteService {
   rename(request: RenameGoalRequest): RenameGoalResult {
   rename(request: RenameGoalRequest): RenameGoalResult {
     return { renamed: request.title.length > 0 }
     return { renamed: request.title.length > 0 }
   }
   }
+
+  @Remote({ mode: 'stream' })
+  async *watch(agent: Agent, signal: AbortSignal): AsyncIterable<CreateGoalResult> {
+    signal.throwIfAborted()
+    yield { ref: agent.id }
+  }
 }
 }
 
 
 export type {
 export type {

+ 1 - 1
packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts

@@ -60,7 +60,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
     context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
     context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
   ): void
   ): void
 
 
-  export function Remote(exportName: string):
+  export function Remote(option: string | { readonly mode: 'stream' }):
   <This extends object, Args extends unknown[], Result>(
   <This extends object, Args extends unknown[], Result>(
     method: (this: This, ...args: Args) => Result,
     method: (this: This, ...args: Args) => Result,
     context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
     context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,

+ 40 - 24
packages/typert/generator/tests/remote-model.spec.ts

@@ -20,6 +20,7 @@ interface RuntimeSchema {
 
 
 interface RuntimeDescriptor {
 interface RuntimeDescriptor {
   readonly id: string
   readonly id: string
+  readonly mode?: 'stream'
   readonly cancellation?: { readonly parameter: 'signal' }
   readonly cancellation?: { readonly parameter: 'signal' }
   readonly parameters: readonly {
   readonly parameters: readonly {
     readonly wire: string
     readonly wire: string
@@ -66,7 +67,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
 
 
     const model = remotePackage(fixtureRoot)
     const model = remotePackage(fixtureRoot)
     expect(model.services).toEqual([])
     expect(model.services).toEqual([])
-    expect(model.invocations).toHaveLength(2)
+    expect(model.invocations).toHaveLength(3)
     expect(model.invocations[0]).toMatchObject({
     expect(model.invocations[0]).toMatchObject({
       id: '@fixture/remote#goals/create',
       id: '@fixture/remote#goals/create',
       service: 'goals',
       service: 'goals',
@@ -111,6 +112,22 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
       }],
       }],
       result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' },
       result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' },
     })
     })
+    expect(model.invocations[2]).toMatchObject({
+      id: '@fixture/remote#goals/watch',
+      service: 'goals',
+      namespace: 'goals',
+      method: 'watch',
+      mode: 'stream',
+      invocation: { kind: 'direct' },
+      parameters: [{
+        name: 'agent',
+        wire: 'agentId',
+        source: 'lookup',
+        lookup: 'agent',
+      }],
+      cancellation: { parameter: 'signal' },
+      result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
+    })
 
 
     expect(artifact?.js).toContain('invocations: [')
     expect(artifact?.js).toContain('invocations: [')
     expect(artifact?.remote?.dts).toContain(
     expect(artifact?.remote?.dts).toContain(
@@ -124,6 +141,9 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
     expect(artifact?.remote?.dts).toContain(
     expect(artifact?.remote?.dts).toContain(
       "'agent:goals/rename': (request: RenameGoalRequest) => Promise<RemoteResult<RenameGoalResult>>",
       "'agent:goals/rename': (request: RenameGoalRequest) => Promise<RemoteResult<RenameGoalResult>>",
     )
     )
+    expect(artifact?.remote?.dts).toContain(
+      "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => AsyncIterable<CreateGoalResult>",
+    )
 
 
     const remoteJs = artifact?.remote?.js
     const remoteJs = artifact?.remote?.js
     if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
     if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
@@ -136,6 +156,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
     expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
     expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
     expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
     expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
     expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
     expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
+    expect(generated.TYPERT_REMOTE.descriptors[2]?.mode).toBe('stream')
 
 
     const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
     const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
     expect(declarationMap).toMatchObject({
     expect(declarationMap).toMatchObject({
@@ -241,17 +262,15 @@ export type GenericResult = {
         '  RenameGoalResult,\n  GenericRequest,\n  GenericResult,\n',
         '  RenameGoalResult,\n  GenericRequest,\n  GenericResult,\n',
       )
       )
       .replace(
       .replace(
-        '  rename(request: RenameGoalRequest): RenameGoalResult {\n    return { renamed: request.title.length > 0 }\n  }\n}',
-        `  rename(request: RenameGoalRequest): RenameGoalResult {
-    return { renamed: request.title.length > 0 }
-  }
-
-  @Remote
+        "  @Remote({ mode: 'stream' })\n  async *watch",
+        `  @Remote
   dispatch(request: GenericRequest): GenericResult {
   dispatch(request: GenericRequest): GenericResult {
     if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
     if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
     return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
     return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
   }
   }
-}`,
+
+  @Remote({ mode: 'stream' })
+  async *watch`,
       ))
       ))
 
 
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
@@ -292,16 +311,14 @@ export interface BoxPayload {
         '  RenameGoalResult,\n  Box,\n  BoxPayload,\n',
         '  RenameGoalResult,\n  Box,\n  BoxPayload,\n',
       )
       )
       .replace(
       .replace(
-        '  rename(request: RenameGoalRequest): RenameGoalResult {\n    return { renamed: request.title.length > 0 }\n  }\n}',
-        `  rename(request: RenameGoalRequest): RenameGoalResult {
-    return { renamed: request.title.length > 0 }
-  }
-
-  @Remote
+        "  @Remote({ mode: 'stream' })\n  async *watch",
+        `  @Remote
   box(request: Box<BoxPayload>): Box<BoxPayload> {
   box(request: Box<BoxPayload>): Box<BoxPayload> {
     return request
     return request
   }
   }
-}`,
+
+  @Remote({ mode: 'stream' })
+  async *watch`,
       ))
       ))
 
 
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
@@ -313,16 +330,14 @@ export interface BoxPayload {
   it('quotes aliased methods in generated namespace interfaces', () => {
   it('quotes aliased methods in generated namespace interfaces', () => {
     const root = copyFixture()
     const root = copyFixture()
     editFile(root, 'packages/remote/src/index.ts', source => source.replace(
     editFile(root, 'packages/remote/src/index.ts', source => source.replace(
-      '  rename(request: RenameGoalRequest): RenameGoalResult {\n    return { renamed: request.title.length > 0 }\n  }\n}',
-      `  rename(request: RenameGoalRequest): RenameGoalResult {
-    return { renamed: request.title.length > 0 }
-  }
-
-  @Remote('create-goal')
+      "  @Remote({ mode: 'stream' })\n  async *watch",
+      `  @Remote('create-goal')
   createAlias(request: CreateGoalRequest): CreateGoalResult {
   createAlias(request: CreateGoalRequest): CreateGoalResult {
     return { ref: request.title }
     return { ref: request.title }
   }
   }
-}`,
+
+  @Remote({ mode: 'stream' })
+  async *watch`,
     ))
     ))
 
 
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
@@ -343,8 +358,9 @@ export interface BoxPayload {
   it('rejects a Remote export after its last Remote method is removed', () => {
   it('rejects a Remote export after its last Remote method is removed', () => {
     const root = copyFixture()
     const root = copyFixture()
     editFile(root, 'packages/remote/src/index.ts', source => source
     editFile(root, 'packages/remote/src/index.ts', source => source
-      .replace('  @Remote\n', '')
-      .replace("  @RemoteScope('agent')\n", ''))
+      .replaceAll('  @Remote\n', '')
+      .replace("  @RemoteScope('agent')\n", '')
+      .replace("  @Remote({ mode: 'stream' })\n", ''))
     editFile(root, 'packages/remote/src/types.ts', source => `${source}
     editFile(root, 'packages/remote/src/types.ts', source => `${source}
 
 
 /** @typert schema */
 /** @typert schema */

+ 70 - 25
packages/typert/protocol/src/index.ts

@@ -5,7 +5,7 @@
  */
  */
 
 
 import { Service, type Context } from '@deepseek-ai/cordis'
 import { Service, type Context } from '@deepseek-ai/cordis'
-import type { TypertContextMap } from './types.ts'
+import type { RemoteFailure, TypertContextMap } from './types.ts'
 
 
 const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
 const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
 
 
@@ -37,22 +37,42 @@ export class TypertLookupFailure<Failure = unknown> extends Error {
   }
   }
 }
 }
 
 
+/** A business Remote rejection preserved by unary and stream carriers. */
+export class TypertRemoteFailure extends Error {
+  /** Stable caller-facing failure payload. */
+  readonly failure: RemoteFailure
+
+  /**
+   * Wrap one business rejection for transport without changing its code or details.
+   * @param failure - business failure returned unchanged to the caller.
+   */
+  constructor(failure: RemoteFailure) {
+    super(failure.message)
+    this.name = 'TypertRemoteFailure'
+    this.failure = failure
+  }
+}
+
 export type {
 export type {
   InvocationDescriptor,
   InvocationDescriptor,
   InvocationParameterDescriptor,
   InvocationParameterDescriptor,
   InvocationSourceLocation,
   InvocationSourceLocation,
   RemoteFailure,
   RemoteFailure,
   RemoteResult,
   RemoteResult,
+  TypertClientEventListener,
   TypertClientRemote,
   TypertClientRemote,
-  TypertClientContextBinder,
+  TypertClientContextAdapter,
   TypertCodec,
   TypertCodec,
   TypertContext,
   TypertContext,
+  TypertContextAdapter,
   TypertContextMap,
   TypertContextMap,
   TypertContextRegistry,
   TypertContextRegistry,
   TypertContextWire,
   TypertContextWire,
   TypertDisposer,
   TypertDisposer,
   TypertForwardableEvent,
   TypertForwardableEvent,
-  TypertHostContextProvider,
+  TypertForwardableEventEntry,
+  TypertHostContextAdapter,
+  TypertHostContextIdentity,
   TypertHostContextResolver,
   TypertHostContextResolver,
   TypertLocalRegistry,
   TypertLocalRegistry,
   TypertLookup,
   TypertLookup,
@@ -103,9 +123,17 @@ export interface RemoteMethodMarker {
   readonly method: string
   readonly method: string
   /** Endpoint method when it differs from the implementation member. */
   /** Endpoint method when it differs from the implementation member. */
   readonly exportName?: string
   readonly exportName?: string
+  /** Stream methods yield many independently validated result items. */
+  readonly mode?: 'stream'
   readonly invocation: RemoteInvocationMarker
   readonly invocation: RemoteInvocationMarker
 }
 }
 
 
+/** Options for a non-unary Remote method. */
+export interface RemoteMethodOptions {
+  /** Deliver each Iterable item over the shared logical-stream carrier. */
+  readonly mode: 'stream'
+}
+
 type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
 type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
   method: (this: This, ...args: Args) => Result,
   method: (this: This, ...args: Args) => Result,
   context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
   context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
@@ -120,6 +148,7 @@ interface RemoteInitializerContext<This extends object> {
 
 
 interface StoredRemoteMethodMarker {
 interface StoredRemoteMethodMarker {
   readonly exportName?: string
   readonly exportName?: string
+  readonly mode?: 'stream'
   readonly invocation: RemoteInvocationMarker
   readonly invocation: RemoteInvocationMarker
 }
 }
 
 
@@ -170,31 +199,47 @@ export function Remote<This extends object, Args extends unknown[], Result>(
   context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
   context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
 ): void
 ): void
 /**
 /**
- * Mark one public instance method under a distinct exported method name.
- * @param exportName - Remote endpoint method, without a namespace or slash.
+ * Mark one public instance method under an exported name or as a logical stream.
+ * @param option - endpoint method name or stream delivery mode.
  * @returns a standard method decorator.
  * @returns a standard method decorator.
  */
  */
-export function Remote(exportName: string): RemoteMethodDecorator
+export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator
 export function Remote<This extends object, Args extends unknown[], Result>(
 export function Remote<This extends object, Args extends unknown[], Result>(
-  methodOrExportName: string | ((this: This, ...args: Args) => Result),
+  methodExportOrOptions: string | RemoteMethodOptions | ((this: This, ...args: Args) => Result),
   context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
   context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
 ): void | RemoteMethodDecorator {
 ): void | RemoteMethodDecorator {
-  if (typeof methodOrExportName === 'string') {
-    validateName('Remote export name', methodOrExportName)
-    return function <DecoratorThis extends object, DecoratorArgs extends unknown[], DecoratorResult>(
-      _method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult,
-      decoratorContext: ClassMethodDecoratorContext<
-        DecoratorThis,
-        (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult
-      >,
-    ): void {
-      addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName)
+  if (typeof methodExportOrOptions === 'string') {
+    validateName('Remote export name', methodExportOrOptions)
+    return remoteDecorator({ kind: 'direct' }, undefined, methodExportOrOptions)
+  }
+  if (typeof methodExportOrOptions === 'object') {
+    if (remoteOptionMode(methodExportOrOptions) !== 'stream'
+      || Reflect.ownKeys(methodExportOrOptions).length !== 1) {
+      throw new TypeError('typert-protocol: Remote options must contain exactly mode: "stream"')
     }
     }
+    return remoteDecorator({ kind: 'direct' }, 'stream')
   }
   }
   if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing')
   if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing')
   addMarkerInitializer(context, { kind: 'direct' })
   addMarkerInitializer(context, { kind: 'direct' })
 }
 }
 
 
+function remoteOptionMode(options: object): unknown {
+  return Reflect.get(options, 'mode') as unknown
+}
+
+function remoteDecorator(
+  invocation: RemoteInvocationMarker,
+  mode?: 'stream',
+  exportName?: string,
+): RemoteMethodDecorator {
+  return function <This extends object, Args extends unknown[], Result>(
+    _method: (this: This, ...args: Args) => Result,
+    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
+  ): void {
+    addMarkerInitializer(context, invocation, mode, exportName)
+  }
+}
+
 /**
 /**
  * Create a decorator for a method resolved from one Remote Scope.
  * Create a decorator for a method resolved from one Remote Scope.
  * @param key - scope key declared through the Context map.
  * @param key - scope key declared through the Context map.
@@ -207,12 +252,7 @@ export function RemoteScope(
 ): RemoteMethodDecorator {
 ): RemoteMethodDecorator {
   validateName('Scope key', key)
   validateName('Scope key', key)
   if (exportName !== undefined) validateName('Remote export name', exportName)
   if (exportName !== undefined) validateName('Remote export name', exportName)
-  return function <This extends object, Args extends unknown[], Result>(
-    _method: (this: This, ...args: Args) => Result,
-    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
-  ): void {
-    addMarkerInitializer(context, { kind: 'context', context: key }, exportName)
-  }
+  return remoteDecorator({ kind: 'context', context: key }, undefined, exportName)
 }
 }
 
 
 /**
 /**
@@ -230,6 +270,7 @@ export function remoteMethods(service: object): readonly RemoteMethodMarker[] {
 function addMarkerInitializer<This extends object>(
 function addMarkerInitializer<This extends object>(
   context: RemoteInitializerContext<This>,
   context: RemoteInitializerContext<This>,
   invocation: RemoteInvocationMarker,
   invocation: RemoteInvocationMarker,
+  mode?: 'stream',
   exportName?: string,
   exportName?: string,
 ): void {
 ): void {
   if (context.private || context.static || typeof context.name !== 'string') {
   if (context.private || context.static || typeof context.name !== 'string') {
@@ -241,7 +282,7 @@ function addMarkerInitializer<This extends object>(
     if (prototype === null) {
     if (prototype === null) {
       throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`)
       throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`)
     }
     }
-    mark(prototype, method, invocation, exportName)
+    mark(prototype, method, invocation, mode, exportName)
   })
   })
 }
 }
 
 
@@ -249,6 +290,7 @@ function mark(
   prototype: object,
   prototype: object,
   method: string,
   method: string,
   invocation: RemoteInvocationMarker,
   invocation: RemoteInvocationMarker,
+  mode?: 'stream',
   exportName?: string,
   exportName?: string,
 ): void {
 ): void {
   let table = markers.get(prototype)
   let table = markers.get(prototype)
@@ -258,11 +300,14 @@ function mark(
   }
   }
   const marker: StoredRemoteMethodMarker = {
   const marker: StoredRemoteMethodMarker = {
     ...(exportName === undefined || exportName === method ? {} : { exportName }),
     ...(exportName === undefined || exportName === method ? {} : { exportName }),
+    ...(mode === undefined ? {} : { mode }),
     invocation: Object.freeze(invocation),
     invocation: Object.freeze(invocation),
   }
   }
   const current = table.get(method)
   const current = table.get(method)
   if (current !== undefined) {
   if (current !== undefined) {
-    if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return
+    if (current.exportName === marker.exportName
+      && current.mode === marker.mode
+      && sameInvocation(current.invocation, invocation)) return
     throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`)
     throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`)
   }
   }
   table.set(method, Object.freeze(marker))
   table.set(method, Object.freeze(marker))

+ 150 - 58
packages/typert/protocol/src/types.ts

@@ -54,7 +54,7 @@ export interface RemoteFailure {
  * What every generated Remote method resolves to. The Remote face itself folds
  * What every generated Remote method resolves to. The Remote face itself folds
  * carrier failures into the error branch, so no consumer wraps a call to
  * carrier failures into the error branch, so no consumer wraps a call to
  * recover one; only assembly faults (arity, an unmounted method, a missing
  * recover one; only assembly faults (arity, an unmounted method, a missing
- * Context binder) still reject.
+ * Context adapter) still reject.
  * @template T - the Host method's business result.
  * @template T - the Host method's business result.
  */
  */
 export type RemoteResult<T> =
 export type RemoteResult<T> =
@@ -64,23 +64,92 @@ export type RemoteResult<T> =
 /** Merge-extensible scoped Remote method signatures generated for consumers. */
 /** Merge-extensible scoped Remote method signatures generated for consumers. */
 export interface TypertRemoteScopeMap {}
 export interface TypertRemoteScopeMap {}
 
 
+type TypertEventParameters<Event extends keyof Events> =
+  Events[Event] extends (...args: infer Args) => unknown ? Args : never
+
+type TypertEventResult<Event extends keyof Events> =
+  Events[Event] extends (...args: never[]) => infer Result ? Result : never
+
+type TypertProjectedContextKey = Extract<keyof TypertLookupMap, keyof TypertContextMap>
+
+type TypertProjectedContextSubject = {
+  [Key in TypertProjectedContextKey]: TypertLookupHost<TypertLookupMap[Key]>
+}[TypertProjectedContextKey]
+
+type TypertAgentScopedRequest<Request> = Request extends object
+  ? 'agent' extends keyof Request
+    ? Exclude<Request['agent'], undefined> extends TypertProjectedContextSubject ? Request : never
+    : never
+  : never
+
+type TypertWaterfallEvent<Event extends keyof Events> =
+  unknown extends ThisParameterType<Events[Event]>
+    ? never
+    : TypertEventParameters<Event> extends [infer Request, infer Next]
+      ? Next extends () => TypertEventResult<Event>
+        ? TypertEventResult<Event> extends Promise<unknown>
+          ? TypertAgentScopedRequest<Request> extends never ? never : Event
+          : never
+        : never
+      : never
+
+type TypertForwardingMode<Event extends keyof Events> =
+  unknown extends ThisParameterType<Events[Event]>
+    ? TypertEventResult<Event> extends void ? 'emit' : never
+    : TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
+
 /**
 /**
- * Cordis event names whose shape a one-way Remote delivery can carry: unbound
- * from any Scope and returning `void`. Which ones are actually forwarded is the
- * Host assembly's selection; this predicate only excludes shapes the carrier
- * cannot represent.
+ * Cordis event names the Remote Event carrier can preserve without a second
+ * signature declaration: unscoped `void` notifications and scoped async
+ * waterfalls whose final parameter is their same-result `next()` callback.
  */
  */
 export type TypertForwardableEvent = {
 export type TypertForwardableEvent = {
-  [Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
-    ? ReturnType<Events[Event]> extends void ? Event : never
+  [Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
+}[keyof Events]
+
+/** Event and dispatch mode accepted by the Remote Event source. */
+export type TypertForwardableEventEntry = {
+  [Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
+    ? Mode extends 'emit' | 'waterfall'
+      ? { readonly event: Event; readonly mode: Mode }
+      : never
     : never
     : never
 }[keyof Events]
 }[keyof Events]
 
 
 /** Merge-extensible forwarding selection declared once by the Host assembly. */
 /** Merge-extensible forwarding selection declared once by the Host assembly. */
 export interface TypertRemoteEventSelection {}
 export interface TypertRemoteEventSelection {}
 
 
-/** Legal `$on` keys: selected events that exist in the current compilation face. */
-export type TypertRemoteEvent = Extract<keyof Events, keyof TypertRemoteEventSelection>
+/** Legal `$on` keys selected from the carrier-compatible Cordis event declarations. */
+export type TypertRemoteEvent = Extract<TypertForwardableEvent, keyof TypertRemoteEventSelection>
+
+type TypertClientAgent<Value> =
+  Exclude<Value, undefined> extends TypertProjectedContextSubject
+    ? Context | Extract<Value, undefined>
+    : Value
+
+type TypertClientEventRequest<Request> = Request extends object
+  ? { [Key in keyof Request]: Key extends 'agent' ? TypertClientAgent<Request[Key]> : Request[Key] }
+  : never
+
+type TypertScopedClientEventListener<Event extends TypertRemoteEvent> =
+  Events[Event] extends (request: infer Request, next: infer Next) => infer Result
+    ? (
+      this: Context,
+      request: TypertClientEventRequest<Request>,
+      next: Next,
+    ) => Result
+    : never
+
+/**
+ * Listener derived from one selected Cordis event declaration. Scoped Host
+ * subjects become the resolved Client `Context`; one-way notifications retain
+ * their declaration unchanged.
+ * @template Event - selected Remote Event name.
+ */
+export type TypertClientEventListener<Event extends TypertRemoteEvent> =
+  unknown extends ThisParameterType<Events[Event]>
+    ? Events[Event]
+    : TypertScopedClientEventListener<Event>
 
 
 /**
 /**
  * Resolve one direct Remote namespace from the generated flat endpoint map.
  * Resolve one direct Remote namespace from the generated flat endpoint map.
@@ -181,6 +250,8 @@ export interface InvocationDescriptor {
   readonly method: string
   readonly method: string
   /** Service member invoked when the exported method name is an alias. */
   /** Service member invoked when the exported method name is an alias. */
   readonly implementation?: string
   readonly implementation?: string
+  /** Absent for unary calls; stream calls validate and deliver every yielded item. */
+  readonly mode?: 'stream'
   /** Receiver selection mode. */
   /** Receiver selection mode. */
   readonly invocation:
   readonly invocation:
     | { readonly kind: 'direct' }
     | { readonly kind: 'direct' }
@@ -192,7 +263,7 @@ export interface InvocationDescriptor {
     }
     }
   /** Optional consuming-Context projection for one direct lookup parameter. */
   /** Optional consuming-Context projection for one direct lookup parameter. */
   readonly scope?: {
   readonly scope?: {
-    /** Context kind whose Client binder supplies the identity. */
+    /** Context kind whose Client adapter supplies the identity. */
     readonly context: string
     readonly context: string
     /** Lookup parameter wire field replaced by the Context identity. */
     /** Lookup parameter wire field replaced by the Context identity. */
     readonly wire: string
     readonly wire: string
@@ -204,7 +275,7 @@ export interface InvocationDescriptor {
     /** Reserved final Host method parameter. */
     /** Reserved final Host method parameter. */
     readonly parameter: 'signal'
     readonly parameter: 'signal'
   }
   }
-  /** Codec for the resolved method result. */
+  /** Codec for the unary result or each yielded stream item. */
   readonly result: TypertCodec
   readonly result: TypertCodec
   /** Source declaration used only for diagnostics. */
   /** Source declaration used only for diagnostics. */
   readonly sourceLocation?: InvocationSourceLocation
   readonly sourceLocation?: InvocationSourceLocation
@@ -227,26 +298,15 @@ export interface TypertClientRemote extends TypertRemoteNamespaceMap {
    */
    */
   $mount(contribution: TypertRemoteContribution): Promise<TypertDisposer>
   $mount(contribution: TypertRemoteContribution): Promise<TypertDisposer>
   /**
   /**
-   * Subscribe to one forwarded Host event; delivery is one-way, in registration
-   * order, and isolates a throwing listener from the rest.
+   * Subscribe to one forwarded Host event. Notifications run in registration
+   * order and isolate failures; scoped waterfalls return, delegate through
+   * `next()`, or reject the Host dispatch.
    * @template Event - forwarded event name selected by the Host assembly.
    * @template Event - forwarded event name selected by the Host assembly.
    * @param event - forwarded Host event name, unchanged on the wire.
    * @param event - forwarded Host event name, unchanged on the wire.
-   * @param listener - receives the Host's argument list as declared by Cordis `Events`.
+   * @param listener - receives the Client projection of the Cordis `Events` declaration.
    * @returns disposer owned by the calling fiber.
    * @returns disposer owned by the calling fiber.
    */
    */
-  $on<Event extends TypertRemoteEvent>(event: Event, listener: Events[Event]): () => void
-  /**
-   * Hand one decoded forwarded frame to the subscription table. The carrier
-   * owning the Host frame sink calls this; a consumer subscribes with
-   * {@link TypertClientRemote.$on} and never calls it.
-   *
-   * `event` is a plain string because this is the wire boundary: the name is
-   * whatever the Host assembly's allowlist selected, and one nobody subscribed
-   * to is dropped silently.
-   * @param event - forwarded Host event name, exactly as the Host emitted it.
-   * @param args - the Host argument list, already JSON-decoded.
-   */
-  $dispatch(event: string, args: readonly unknown[]): void
+  $on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<Event>): () => void
 }
 }
 
 
 /**
 /**
@@ -290,33 +350,58 @@ export interface TypertLookupDefinition {
   readonly wireTypeSymbol: string
   readonly wireTypeSymbol: string
 }
 }
 
 
-/** Host resolver for one scoped Remote kind. */
-export interface TypertHostContextProvider<Wire = unknown> {
-  /** Wire field carrying the Context identity. */
-  readonly wire: string
-  /** Canonical wire type symbol used by strict generation. */
-  readonly wireTypeSymbol: string
+/** Bidirectional projection between one environment's Context and its wire identity. */
+export interface TypertContextAdapter<Wire = unknown> {
   /**
   /**
-   * Resolve a wire identity to its live scoped Context.
+   * Read the identity represented by a live Context.
+   * @param ctx - Context in this adapter's environment.
+   * @returns the wire identity, or `undefined` when the Context has another kind.
+   */
+  identity(ctx: Context): Wire | undefined
+  /**
+   * Resolve a wire identity to a live Context in this adapter's environment.
+   * An asynchronous Client resolver may wait for its owner to create the Context.
    * @param id - validated wire identity.
    * @param id - validated wire identity.
-   * @returns the scoped Context, or `undefined` when unavailable.
+   * @returns the Context, or `undefined` when it is unavailable.
    */
    */
   resolve(id: Wire): Context | undefined | Promise<Context | undefined>
   resolve(id: Wire): Context | undefined | Promise<Context | undefined>
 }
 }
 
 
-/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */
+/** Host Context adapter plus the wire declaration used by strict Remote methods. */
+export interface TypertHostContextAdapter<Wire = unknown> extends TypertContextAdapter<Wire> {
+  /** Wire field carrying the Context identity. */
+  readonly wire: string
+  /** Canonical wire type symbol used by strict generation. */
+  readonly wireTypeSymbol: string
+}
+
+/** Composition-owned resolver replacing one Host Context adapter's default lookup policy. */
 export type TypertHostContextResolver<Wire = unknown> = (
 export type TypertHostContextResolver<Wire = unknown> = (
   id: Wire,
   id: Wire,
 ) => Context | undefined | Promise<Context | undefined>
 ) => Context | undefined | Promise<Context | undefined>
 
 
-/** Client resolver for the identity carried by the calling scoped Context. */
-export interface TypertClientContextBinder<Wire = unknown> {
+/** Client-side bidirectional Context adapter. */
+export interface TypertClientContextAdapter<Wire = unknown> {
   /**
   /**
-   * Read the Remote identity represented by a calling Context.
-   * @param ctx - Context rebound by the Cordis service tracker.
-   * @returns the wire identity, or `undefined` when the Context has the wrong scope.
+   * Read the identity represented by a live Client Context.
+   * @param ctx - Client Context inspected by a scoped Remote caller.
+   * @returns the wire identity, or `undefined` for another Context kind.
    */
    */
   identity(ctx: Context): Wire | undefined
   identity(ctx: Context): Wire | undefined
+  /**
+   * Resolve a wire identity from the Client's currently materialized Contexts.
+   * @param id - validated wire identity.
+   * @returns the Client Context, or `undefined` when unavailable.
+   */
+  resolve(id: Wire): Context | undefined
+}
+
+/** Host Context identity selected from the registered adapter set. */
+export interface TypertHostContextIdentity {
+  /** Merge-declared Context kind whose adapter recognized the Context. */
+  readonly kind: string
+  /** Wire identity returned by that adapter. */
+  readonly identity: unknown
 }
 }
 
 
 /** Notification emitted after a Typert runtime registry changes. */
 /** Notification emitted after a Typert runtime registry changes. */
@@ -423,20 +508,20 @@ export interface TypertLookupRegistry {
   subscribe(listener: TypertRegistryListener): TypertDisposer
   subscribe(listener: TypertRegistryListener): TypertDisposer
 }
 }
 
 
-/** Runtime registry for Host Context resolvers and Client Context binders. */
+/** Runtime registry for the Host and Client adapters of each Context kind. */
 export interface TypertContextRegistry {
 export interface TypertContextRegistry {
   /**
   /**
-   * Register a Host Context resolver.
+   * Register a Host Context adapter.
    * @param key - merge-declared Context key.
    * @param key - merge-declared Context key.
-   * @param provider - owning package's Host resolver.
-   * @returns disposer withdrawing the exact provider.
+   * @param adapter - owning package's bidirectional Host projection.
+   * @returns disposer withdrawing the exact adapter.
    */
    */
   registerHost<K extends StringKeyOf<TypertContextMap>>(
   registerHost<K extends StringKeyOf<TypertContextMap>>(
     key: K,
     key: K,
-    provider: TypertHostContextProvider<TypertContextWire<TypertContextMap[K]>>,
+    adapter: TypertHostContextAdapter<TypertContextWire<TypertContextMap[K]>>,
   ): TypertDisposer
   ): TypertDisposer
   /**
   /**
-   * Override one Host Context key's identity policy for the calling fiber.
+   * Override one Host Context key's resolution policy for the calling fiber.
    * Configuration may precede provider registration and restores the provider's default resolver on disposal.
    * Configuration may precede provider registration and restores the provider's default resolver on disposal.
    * @param key - merge-declared Context key.
    * @param key - merge-declared Context key.
    * @param resolver - composition-owned resolver used by every Host Context lookup of this key.
    * @param resolver - composition-owned resolver used by every Host Context lookup of this key.
@@ -447,29 +532,36 @@ export interface TypertContextRegistry {
     resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
     resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
   ): TypertDisposer
   ): TypertDisposer
   /**
   /**
-   * Register a Client Context identity binder.
+   * Register a Client Context adapter.
    * @param key - merge-declared Context key.
    * @param key - merge-declared Context key.
-   * @param binder - Client scope identity resolver.
-   * @returns disposer withdrawing the exact binder.
+   * @param adapter - owning package's bidirectional Client projection.
+   * @returns disposer withdrawing the exact adapter.
    */
    */
   registerClient<K extends StringKeyOf<TypertContextMap>>(
   registerClient<K extends StringKeyOf<TypertContextMap>>(
     key: K,
     key: K,
-    binder: TypertClientContextBinder<TypertContextWire<TypertContextMap[K]>>,
+    adapter: TypertClientContextAdapter<TypertContextWire<TypertContextMap[K]>>,
   ): TypertDisposer
   ): TypertDisposer
   /**
   /**
-   * Look up a Host Context resolver.
+   * Identify a live Host Context through the sole registered adapter set.
+   * @param ctx - Context projected by a Host-to-Client scoped event.
+   * @returns its kind and wire identity, or `undefined` when no adapter recognizes it.
+   * @throws when more than one Context kind recognizes the same Context.
+   */
+  identifyHost(ctx: Context): TypertHostContextIdentity | undefined
+  /**
+   * Look up a Host Context adapter.
    * @param key - descriptor Context key.
    * @param key - descriptor Context key.
-   * @returns the provider, or `undefined` when absent.
+   * @returns the adapter, or `undefined` when absent.
    */
    */
-  getHost(key: string): TypertHostContextProvider | undefined
+  getHost(key: string): TypertHostContextAdapter | undefined
   /**
   /**
-   * Look up a Client Context binder.
+   * Look up a Client Context adapter.
    * @param key - descriptor Context key.
    * @param key - descriptor Context key.
-   * @returns the binder, or `undefined` when absent.
+   * @returns the adapter, or `undefined` when absent.
    */
    */
-  getClient(key: string): TypertClientContextBinder | undefined
+  getClient(key: string): TypertClientContextAdapter | undefined
   /**
   /**
-   * Observe later Context provider changes.
+   * Observe later Context adapter changes.
    * @param listener - synchronous contained observer.
    * @param listener - synchronous contained observer.
    * @returns disposer for this subscription.
    * @returns disposer for this subscription.
    */
    */

+ 72 - 2
packages/typert/protocol/tests/protocol.spec.ts

@@ -8,11 +8,25 @@ import {
   Remote,
   Remote,
   RemoteScope,
   RemoteScope,
   remoteMethods,
   remoteMethods,
+  type TypertClientEventListener,
   type TypertContext,
   type TypertContext,
   type TypertForwardableEvent,
   type TypertForwardableEvent,
+  type TypertForwardableEventEntry,
+  type TypertLookup,
   type TypertRemoteEvent,
   type TypertRemoteEvent,
 } from '@deepseek-ai/dsh-typert-protocol'
 } from '@deepseek-ai/dsh-typert-protocol'
 
 
+interface MetaFixtureSubject {
+  readonly subjectId: string
+}
+
+interface MetaFixtureRequest {
+  readonly agent: MetaFixtureSubject
+  readonly signal?: AbortSignal
+  readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }]
+  readonly transform: (subject: MetaFixtureSubject) => Promise<MetaFixtureSubject | undefined>
+}
+
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
   interface Events {
   interface Events {
     /**
     /**
@@ -25,6 +39,17 @@ declare module '@deepseek-ai/cordis' {
      * @param value - marker payload.
      * @param value - marker payload.
      */
      */
     'meta-fixture/scoped'(this: Context, value: string): void
     'meta-fixture/scoped'(this: Context, value: string): void
+    /**
+     * Test-only scoped waterfall whose result can make the return trip.
+     * @param value - marker payload.
+     * @param next - delegates to the next listener.
+     * @returns the claimed or delegated value.
+     */
+    'meta-fixture/waterfall'(
+      this: Context,
+      request: MetaFixtureRequest,
+      next: () => Promise<string>,
+    ): Promise<string>
     /**
     /**
      * Test-only answered event, whose result no one-way delivery can return.
      * Test-only answered event, whose result no one-way delivery can return.
      * @param value - marker payload.
      * @param value - marker payload.
@@ -35,12 +60,16 @@ declare module '@deepseek-ai/cordis' {
 }
 }
 
 
 declare module '@deepseek-ai/dsh-typert-protocol' {
 declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface TypertLookupMap {
+    metaFixture: TypertLookup<MetaFixtureSubject, string>
+  }
+
   interface TypertContextMap {
   interface TypertContextMap {
     metaFixture: TypertContext<string>
     metaFixture: TypertContext<string>
   }
   }
 
 
   interface TypertRemoteEventSelection extends
   interface TypertRemoteEventSelection extends
-    Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {}
+    Record<'meta-fixture/forwardable' | 'meta-fixture/waterfall' | 'meta-fixture/absent', true> {}
 }
 }
 
 
 describe('typert-protocol Remote declarations', () => {
 describe('typert-protocol Remote declarations', () => {
@@ -55,6 +84,11 @@ describe('typert-protocol Remote declarations', () => {
         return value
         return value
       }
       }
 
 
+      @Remote({ mode: 'stream' })
+      *watch(): Iterable<string> {
+        yield 'value'
+      }
+
       @RemoteScope('metaFixture')
       @RemoteScope('metaFixture')
       scoped(value: string): string {
       scoped(value: string): string {
         return value
         return value
@@ -78,6 +112,7 @@ describe('typert-protocol Remote declarations', () => {
     })
     })
     expect(remoteMethods(goals)).toEqual([
     expect(remoteMethods(goals)).toEqual([
       { method: 'create', invocation: { kind: 'direct' } },
       { method: 'create', invocation: { kind: 'direct' } },
+      { method: 'watch', mode: 'stream', invocation: { kind: 'direct' } },
       { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
       { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
     ])
     ])
     await ctx.fiber.dispose()
     await ctx.fiber.dispose()
@@ -192,6 +227,8 @@ describe('typert-protocol Remote declarations', () => {
     expect(() => Remote('bad name')).toThrow('export name')
     expect(() => Remote('bad name')).toThrow('export name')
     expect(() => Remote('.')).toThrow('export name')
     expect(() => Remote('.')).toThrow('export name')
     expect(() => Remote('..')).toThrow('export name')
     expect(() => Remote('..')).toThrow('export name')
+    expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode')
+    expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode')
     expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key')
     expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key')
     expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name')
     expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name')
 
 
@@ -213,6 +250,12 @@ describe('typert-protocol Remote declarations', () => {
     Reflect.setPrototypeOf(prototypeLess, null)
     Reflect.setPrototypeOf(prototypeLess, null)
     expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype')
     expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype')
 
 
+    const stream: Array<(this: object) => void> = []
+    Remote({ mode: 'stream' })(method, methodContext('run', stream))
+    const conflict = Object.create({}) as object
+    direct[0]!.call(conflict)
+    expect(() => { stream[0]!.call(conflict) }).toThrow('conflicting invocation markers')
+
     class Service {
     class Service {
       run(): void {}
       run(): void {}
     }
     }
@@ -236,14 +279,41 @@ describe('typert-protocol Remote declarations', () => {
     expect(() => bindTypertRemote({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
     expect(() => bindTypertRemote({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
   })
   })
 
 
-  it('admits only one-way event shapes and only selected events that exist', () => {
+  it('admits notifications and same-result scoped waterfalls selected from Cordis Events', () => {
     expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypertForwardableEvent>()
+    expectTypeOf<'meta-fixture/waterfall'>().toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/answered'>().not.toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/answered'>().not.toExtend<TypertForwardableEvent>()
 
 
     expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypertRemoteEvent>()
     expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypertRemoteEvent>()
+    expectTypeOf<'meta-fixture/waterfall'>().toExtend<TypertRemoteEvent>()
     expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypertRemoteEvent>()
     expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypertRemoteEvent>()
     expectTypeOf<'meta-fixture/absent'>().not.toExtend<TypertRemoteEvent>()
     expectTypeOf<'meta-fixture/absent'>().not.toExtend<TypertRemoteEvent>()
+
+    expectTypeOf<{ event: 'meta-fixture/forwardable'; mode: 'emit' }>()
+      .toExtend<TypertForwardableEventEntry>()
+    expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'waterfall' }>()
+      .toExtend<TypertForwardableEventEntry>()
+    expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'emit' }>()
+      .not.toExtend<TypertForwardableEventEntry>()
+  })
+
+  it('derives Client Context arguments from the selected Cordis waterfall declaration', () => {
+    type ExpectedListener = (
+      this: Context,
+      request: {
+        readonly agent: Context
+        readonly signal?: AbortSignal
+        readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }]
+        readonly transform: (subject: MetaFixtureSubject) => Promise<MetaFixtureSubject | undefined>
+      },
+      next: () => Promise<string>,
+    ) => Promise<string>
+
+    expectTypeOf<TypertClientEventListener<'meta-fixture/waterfall'>>()
+      .toEqualTypeOf<ExpectedListener>()
+    expectTypeOf<TypertClientEventListener<'meta-fixture/forwardable'>>()
+      .toEqualTypeOf<(value: string) => void>()
   })
   })
 })
 })
 
 

+ 38 - 21
packages/typert/registry/src/service.ts

@@ -9,12 +9,12 @@ import { Context, Service } from '@deepseek-ai/cordis'
 import { z } from 'zod'
 import { z } from 'zod'
 import type {
 import type {
   InvocationDescriptor,
   InvocationDescriptor,
-  TypertClientContextBinder,
+  TypertClientContextAdapter,
   TypertContextMap,
   TypertContextMap,
   TypertContextRegistry,
   TypertContextRegistry,
   TypertContextWire,
   TypertContextWire,
   TypertDisposer,
   TypertDisposer,
-  TypertHostContextProvider,
+  TypertHostContextAdapter,
   TypertHostContextResolver,
   TypertHostContextResolver,
   TypertLocalRegistry,
   TypertLocalRegistry,
   TypertLookupHost,
   TypertLookupHost,
@@ -334,9 +334,9 @@ function lookupDefinitionEquals(left: TypertLookupDefinition, right: TypertLooku
 }
 }
 
 
 class ContextStore {
 class ContextStore {
-  private readonly hosts = new Map<string, ProviderEntry<TypertHostContextProvider>>()
+  private readonly hosts = new Map<string, ProviderEntry<TypertHostContextAdapter>>()
   private readonly hostResolvers = new Map<string, ProviderEntry<HostContextResolverEntry>>()
   private readonly hostResolvers = new Map<string, ProviderEntry<HostContextResolverEntry>>()
-  private readonly clients = new Map<string, ProviderEntry<TypertClientContextBinder>>()
+  private readonly clients = new Map<string, ProviderEntry<TypertClientContextAdapter>>()
   private readonly changes: ChangeSource
   private readonly changes: ChangeSource
 
 
   constructor(report: ReportObserverError) {
   constructor(report: ReportObserverError) {
@@ -347,34 +347,51 @@ class ContextStore {
     return {
     return {
       registerHost: <K extends Extract<keyof TypertContextMap, string>>(
       registerHost: <K extends Extract<keyof TypertContextMap, string>>(
         key: K,
         key: K,
-        provider: TypertHostContextProvider<TypertContextWire<TypertContextMap[K]>>,
-      ) => this.registerHost(ctx, key, provider),
+        adapter: TypertHostContextAdapter<TypertContextWire<TypertContextMap[K]>>,
+      ) => this.registerHost(ctx, key, adapter),
       configureHost: <K extends Extract<keyof TypertContextMap, string>>(
       configureHost: <K extends Extract<keyof TypertContextMap, string>>(
         key: K,
         key: K,
         resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
         resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
       ) => this.configureHost(ctx, key, resolver),
       ) => this.configureHost(ctx, key, resolver),
       registerClient: <K extends Extract<keyof TypertContextMap, string>>(
       registerClient: <K extends Extract<keyof TypertContextMap, string>>(
         key: K,
         key: K,
-        binder: TypertClientContextBinder<TypertContextWire<TypertContextMap[K]>>,
-      ) => this.registerClient(ctx, key, binder),
+        adapter: TypertClientContextAdapter<TypertContextWire<TypertContextMap[K]>>,
+      ) => this.registerClient(ctx, key, adapter),
+      identifyHost: context => this.identifyHost(context),
       getHost: key => this.getHost(key),
       getHost: key => this.getHost(key),
       getClient: key => this.clients.get(key)?.provider,
       getClient: key => this.clients.get(key)?.provider,
       subscribe: listener => this.changes.subscribe(ctx, listener),
       subscribe: listener => this.changes.subscribe(ctx, listener),
     }
     }
   }
   }
 
 
-  private getHost(key: string): TypertHostContextProvider | undefined {
-    const provider = this.hosts.get(key)?.provider
-    if (provider === undefined) return undefined
+  private getHost(key: string): TypertHostContextAdapter | undefined {
+    const adapter = this.hosts.get(key)?.provider
+    if (adapter === undefined) return undefined
     const resolver = this.hostResolvers.get(key)?.provider
     const resolver = this.hostResolvers.get(key)?.provider
-    if (resolver === undefined) return provider
+    if (resolver === undefined) return adapter
     return {
     return {
-      wire: provider.wire,
-      wireTypeSymbol: provider.wireTypeSymbol,
+      wire: adapter.wire,
+      wireTypeSymbol: adapter.wireTypeSymbol,
+      identity: context => adapter.identity(context),
       resolve: id => resolver.resolve(id),
       resolve: id => resolver.resolve(id),
     }
     }
   }
   }
 
 
+  private identifyHost(ctx: Context): ReturnType<TypertContextRegistry['identifyHost']> {
+    let match: ReturnType<TypertContextRegistry['identifyHost']>
+    for (const key of this.hosts.keys()) {
+      const identity = this.getHost(key)?.identity(ctx)
+      if (identity === undefined) continue
+      if (match !== undefined) {
+        throw new Error(
+          `typert: Host Context is recognized by both ${JSON.stringify(match.kind)} and ${JSON.stringify(key)}`,
+        )
+      }
+      match = { kind: key, identity }
+    }
+    return match
+  }
+
   private configureHost<Wire>(
   private configureHost<Wire>(
     ctx: Context,
     ctx: Context,
     key: string,
     key: string,
@@ -399,16 +416,16 @@ class ContextStore {
     }, `typert.contexts.configureHost(${JSON.stringify(key)})`)
     }, `typert.contexts.configureHost(${JSON.stringify(key)})`)
   }
   }
 
 
-  private registerHost<Wire>(ctx: Context, key: string, provider: TypertHostContextProvider<Wire>): TypertDisposer {
+  private registerHost<Wire>(ctx: Context, key: string, adapter: TypertHostContextAdapter<Wire>): TypertDisposer {
     validateSegment('Context key', key)
     validateSegment('Context key', key)
-    validateWireName('Context wire field', provider.wire)
-    validateNonempty('Context wire type symbol', provider.wireTypeSymbol)
-    return this.registerProvider(ctx, this.hosts, 'host-context', key, provider)
+    validateWireName('Context wire field', adapter.wire)
+    validateNonempty('Context wire type symbol', adapter.wireTypeSymbol)
+    return this.registerProvider(ctx, this.hosts, 'host-context', key, adapter)
   }
   }
 
 
-  private registerClient<Wire>(ctx: Context, key: string, binder: TypertClientContextBinder<Wire>): TypertDisposer {
+  private registerClient<Wire>(ctx: Context, key: string, adapter: TypertClientContextAdapter<Wire>): TypertDisposer {
     validateSegment('Context key', key)
     validateSegment('Context key', key)
-    return this.registerProvider(ctx, this.clients, 'client-context', key, binder)
+    return this.registerProvider(ctx, this.clients, 'client-context', key, adapter)
   }
   }
 
 
   private registerProvider<Provider>(
   private registerProvider<Provider>(
@@ -484,7 +501,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract {
     return this.lookupStore.view(this.ctx)
     return this.lookupStore.view(this.ctx)
   }
   }
 
 
-  /** Host Context providers and Client Context binders. */
+  /** Host and Client Context adapters. */
   get contexts(): TypertContextRegistry {
   get contexts(): TypertContextRegistry {
     return this.contextStore.view(this.ctx)
     return this.contextStore.view(this.ctx)
   }
   }

+ 38 - 1
packages/typert/registry/tests/typert.spec.ts

@@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
 
 
   interface TypertContextMap {
   interface TypertContextMap {
     registryFixture: TypertContext<string>
     registryFixture: TypertContext<string>
+    registryFixtureOther: TypertContext<string>
   }
   }
 }
 }
 
 
@@ -331,10 +332,12 @@ describe('TypertRegistry', () => {
     const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
     const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
       wire: 'agentId',
       wire: 'agentId',
       wireTypeSymbol: '@fixture/session#SessionId',
       wireTypeSymbol: '@fixture/session#SessionId',
+      identity: candidate => candidate === scoped ? object.id : undefined,
       resolve: id => id === object.id ? scoped : undefined,
       resolve: id => id === object.id ? scoped : undefined,
     })
     })
     const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
     const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
       identity: candidate => candidate === scoped ? object.id : undefined,
       identity: candidate => candidate === scoped ? object.id : undefined,
+      resolve: id => id === object.id ? scoped : undefined,
     })
     })
 
 
     expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
     expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
@@ -345,8 +348,15 @@ describe('TypertRegistry', () => {
       hostTypeSymbol: '@fixture/agent#Agent',
       hostTypeSymbol: '@fixture/agent#Agent',
       wireTypeSymbol: '@fixture/session#SessionId',
       wireTypeSymbol: '@fixture/session#SessionId',
     }])
     }])
+    expect(ctx.typert.contexts.getHost('registryFixture')?.identity(scoped)).toBe('agent-1')
     expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
     expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
     expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
     expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
+    expect(ctx.typert.contexts.getClient('registryFixture')?.resolve('agent-1')).toBe(scoped)
+    expect(ctx.typert.contexts.identifyHost(scoped)).toEqual({
+      kind: 'registryFixture',
+      identity: 'agent-1',
+    })
+    expect(ctx.typert.contexts.identifyHost(ctx)).toBeUndefined()
 
 
     await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
     await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
     expect(ctx.typert.lookups.keys()).toEqual([])
     expect(ctx.typert.lookups.keys()).toEqual([])
@@ -355,6 +365,26 @@ describe('TypertRegistry', () => {
     expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
     expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
   })
   })
 
 
+  it('rejects a Host Context recognized by more than one registered kind', async () => {
+    const ctx = await makeCtx()
+    const scoped = ctx.extend()
+    ctx.typert.contexts.registerHost('registryFixture', {
+      wire: 'agentId',
+      wireTypeSymbol: '@fixture#AgentId',
+      identity: candidate => candidate === scoped ? 'first' : undefined,
+      resolve: () => undefined,
+    })
+    ctx.typert.contexts.registerHost('registryFixtureOther', {
+      wire: 'otherAgentId',
+      wireTypeSymbol: '@fixture#OtherAgentId',
+      identity: candidate => candidate === scoped ? 'second' : undefined,
+      resolve: () => undefined,
+    })
+
+    expect(() => ctx.typert.contexts.identifyHost(scoped))
+      .toThrow('recognized by both "registryFixture" and "registryFixtureOther"')
+  })
+
   it('configures an asynchronous lookup resolver independently of provider load order', async () => {
   it('configures an asynchronous lookup resolver independently of provider load order', async () => {
     const ctx = await makeCtx()
     const ctx = await makeCtx()
     const fallback = { id: 'fallback' }
     const fallback = { id: 'fallback' }
@@ -400,9 +430,11 @@ describe('TypertRegistry', () => {
     const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', {
     const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', {
       wire: 'agentId',
       wire: 'agentId',
       wireTypeSymbol: '@fixture/session#SessionId',
       wireTypeSymbol: '@fixture/session#SessionId',
+      identity: candidate => candidate === fallback ? 'fallback' : undefined,
       resolve: id => id === 'fallback' ? fallback : undefined,
       resolve: id => id === 'fallback' ? fallback : undefined,
     })
     })
     await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
     await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
+    expect(ctx.typert.contexts.getHost('registryFixture')?.identity(fallback)).toBe('fallback')
     expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured')
     expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured')
 
 
     await disposeProvider()
     await disposeProvider()
@@ -410,6 +442,7 @@ describe('TypertRegistry', () => {
     const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', {
     const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', {
       wire: 'agentId',
       wire: 'agentId',
       wireTypeSymbol: '@fixture/session#SessionId',
       wireTypeSymbol: '@fixture/session#SessionId',
+      identity: candidate => candidate === fallback ? 'fallback' : undefined,
       resolve: id => id === 'fallback' ? fallback : undefined,
       resolve: id => id === 'fallback' ? fallback : undefined,
     })
     })
     await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
     await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
@@ -438,9 +471,13 @@ describe('TypertRegistry', () => {
     const host = {
     const host = {
       wire: 'agentId',
       wire: 'agentId',
       wireTypeSymbol: '@fixture#AgentId',
       wireTypeSymbol: '@fixture#AgentId',
+      identity: (_candidate: Context) => undefined,
+      resolve: () => undefined,
+    }
+    const client = {
+      identity: (_candidate: Context) => undefined,
       resolve: () => undefined,
       resolve: () => undefined,
     }
     }
-    const client = { identity: () => undefined }
     const disposeLookup = ctx.typert.lookups.register('fixture', lookup)
     const disposeLookup = ctx.typert.lookups.register('fixture', lookup)
     const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host)
     const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host)
     const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client)
     const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client)