Prechádzať zdrojové kódy

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

imccyu 3 týždňov pred
rodič
commit
3d6d595d79
48 zmenil súbory, kde vykonal 7918 pridanie a 538 odobranie
  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 '@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'
 
-/** User-interaction service required by the tripwire provider. */
+/** User-interaction service required by the tripwire answerer. */
 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 {
-  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 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 {
   InvocationDescriptor,
+  TypertClientEventListener,
   TypertClientRemote,
   RemoteResult,
   TypertCodec,
@@ -16,6 +19,29 @@ import type {
   TypertRemoteContribution,
   TypertRemoteEvent,
 } 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 {
   active: boolean
@@ -47,11 +73,21 @@ interface BoundContextIdentity {
   readonly value: unknown
 }
 
+interface PreparedClientInvocation {
+  readonly endpoint: string
+  readonly args: Readonly<Record<string, unknown>>
+  readonly signal: AbortSignal
+}
+
 interface RemoteNamespaceHandle {
   readonly service: RemoteNamespaceService
   readonly dispose: TypertDisposer
 }
 
+interface LoaderReadiness {
+  await(): Promise<unknown>
+}
+
 /** One descriptor's mounted variants, for the group disposer to unwind. */
 interface InstalledMethod {
   readonly descriptor: InvocationDescriptor
@@ -60,8 +96,15 @@ interface InstalledMethod {
   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' {
   interface Context {
@@ -81,28 +124,46 @@ export function apply(ctx: Context): void {
   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 connection: ConnectionHandle
   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()
 
   constructor(ctx: Context) {
     super(ctx, 'remote')
     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']> {
@@ -117,60 +178,24 @@ class ClientRemoteService extends Service implements TypertClientRemote {
 
   $on<Event extends TypertRemoteEvent>(
     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> {
@@ -331,12 +356,12 @@ class ClientRemoteService extends Service implements TypertClientRemote {
     scoped: ScopedMethod | undefined,
     callerCtx: Context,
     values: readonly unknown[],
-  ): Promise<RemoteResult<unknown>> {
+  ): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
     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) {
-        return this.invoke(
+        return this.invokeSelected(
           scoped.descriptor,
           scoped.projection,
           scoped.token,
@@ -347,14 +372,28 @@ class ClientRemoteService extends Service implements TypertClientRemote {
       }
     }
     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) {
-      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')
   }
 
+  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(
     descriptor: InvocationDescriptor,
     projection: ScopedProjection | undefined,
@@ -365,6 +404,48 @@ class ClientRemoteService extends Service implements TypertClientRemote {
   ): Promise<RemoteResult<unknown>> {
     const endpoint = endpointOf(descriptor)
     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 hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
     if (values.length !== expected && !hasCallerSignal) {
@@ -377,14 +458,14 @@ class ClientRemoteService extends Service implements TypertClientRemote {
     }
     const args = Object.create(null) as Record<string, unknown>
     if (projection !== undefined) {
-      const binder = boundIdentity === undefined
+      const adapter = boundIdentity === undefined
         ? this.ownerCtx.typert.contexts.getClient(projection.context)
         : 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
-        ? binder?.identity(callerCtx)
+        ? adapter?.identity(callerCtx)
         : boundIdentity.value
       if (identity === undefined) {
         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
       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 signal = callerSignal === undefined
       ? token.abort.signal
       : 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,
   callerCtx: Context,
   args: readonly unknown[],
-) => Promise<RemoteResult<unknown>>
+) => Promise<RemoteResult<unknown>> | AsyncIterable<unknown>
 
 class RemoteNamespaceService extends Service {
   private readonly methods = new Map<string, RemoteMethodRecord>()
@@ -477,7 +547,7 @@ class RemoteNamespaceService extends Service {
       Object.defineProperty(this, method, {
         configurable: true,
         enumerable: true,
-        get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
+        get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown {
           const callerCtx = this.ctx
           const current = this.methods.get(method)
           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. */
-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`)
 }
 
-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)}`)
 }
 
-function internalFailure(message: string): RemoteResult<never> {
+function internalFailure(message: string): Extract<RemoteResult<never>, { readonly ok: false }> {
   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.
- * 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
  */
 
+import { randomUUID } from 'node:crypto'
 import { Context, Service, symbols } from '@deepseek-ai/cordis'
 import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
+import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
 import {
   remoteMethods,
   TypertLookupFailure,
+  TypertRemoteFailure,
   type InvocationDescriptor,
   type InvocationParameterDescriptor,
   type TypertCodec,
@@ -18,12 +22,47 @@ import type {
   InvokeRemoteRequest,
   TypertGateway,
   TypertGatewayErrorCode,
+  TypertGatewayWireStream,
+  TypertRemoteEventDispatch,
+  TypertRemoteEventFrame,
+  TypertRemoteEventInvocation,
+  TypertRemoteEventOutcome,
+  TypertRemoteEventSource,
 } 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 {
   InvokeRemoteRequest,
   TypertGateway,
   TypertGatewayErrorCode,
+  TypertGatewayWireStream,
+  TypertRemoteEventContext,
+  TypertRemoteEventDispatch,
+  TypertRemoteEventFrame,
+  TypertRemoteEventInvocation,
+  TypertRemoteEventOutcome,
+  TypertRemoteEventSource,
 } from './types.ts'
 
 interface GatewayErrorOptions {
@@ -36,6 +75,34 @@ interface ResolvedBinding {
   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 ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
 const NEVER_ABORTED_SIGNAL = new AbortController().signal
@@ -90,7 +157,16 @@ class RemoteInvocationCancelled extends Error {
 export class TypertGatewayService extends Service implements TypertGateway {
   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 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.
@@ -109,9 +185,63 @@ export class TypertGatewayService extends Service implements TypertGateway {
         { 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 {
+    if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true
     const segments = endpoint.split('/')
     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
@@ -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.
    */
   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(
-        '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
     try {
-      result = await Reflect.apply(method, receiver, args) as unknown
+      result = await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
     } 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
     }
     // 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
     // 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(
@@ -188,30 +338,240 @@ export class TypertGatewayService extends Service implements TypertGateway {
     payload: unknown,
     signal: AbortSignal,
   ): 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)
   }
 
-  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 {
-      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;
       // JSON has no `undefined`, and the envelope's optional slot is the one
       // 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 {
     const strict = this.ctx.typert.local.get(endpoint)
     if (strict !== undefined) return strict
@@ -349,6 +738,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
       namespace: binding.namespace,
       method,
       ...(marker.method === method ? {} : { implementation: marker.method }),
+      ...(marker.mode === undefined ? {} : { mode: marker.mode }),
       invocation: receiver,
       parameters,
       ...(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 {
   if (error instanceof RemoteInvocationCancelled) {
     return {
@@ -478,6 +983,9 @@ function rpcFailure(error: unknown): ConnectionRpcResult {
   if (error instanceof TypertLookupFailure) {
     return { ok: false, error: error.failure as ConnectionRpcError }
   }
+  if (error instanceof TypertRemoteFailure) {
+    return { ok: false, error: error.failure }
+  }
   return {
     ok: false,
     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 {
   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
  */
 
+import type { Context } from '@deepseek-ai/cordis'
+
 /** One Remote method request after a carrier has decoded its envelope. */
 export interface InvokeRemoteRequest {
   /** Remote namespace selected by the generated descriptor. */
@@ -15,6 +17,85 @@ export interface InvokeRemoteRequest {
   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. */
 export type TypertGatewayErrorCode =
   | 'ambiguous-endpoint'
@@ -37,6 +118,16 @@ export type TypertGatewayErrorCode =
 
 /** Host dispatcher consumed by Connection adapters. */
 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.
    * @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.
    */
   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' {

+ 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 { describe, expect, expectTypeOf, it, vi } from 'vitest'
 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 {
   InvocationDescriptor,
   RemoteResult,
-  TypertClientRemote,
+  TypertContextMap,
+  TypertContextWire,
   TypertContext,
+  TypertLookup,
   TypertRemoteScopeApi,
   TypertRemoteNamespace,
 } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 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' {
   interface Events {
@@ -27,6 +47,21 @@ declare module '@deepseek-ai/cordis' {
      * @param count - marker payload never observed.
      */
     '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.
      * @param flag - marker payload never delivered.
@@ -36,12 +71,17 @@ declare module '@deepseek-ai/cordis' {
 }
 
 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 {
     fixture: TypertContext<string>
   }
 
+  interface TypertLookupMap {
+    fixture: TypertLookup<FixtureAgent, string>
+  }
+
   interface TypertRemoteMap {
     'probe/create': (
       agentId: string,
@@ -49,6 +89,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
       signal?: AbortSignal,
     ) => Promise<RemoteResult<{ readonly ref: string }>>
     'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
+    'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable<string>
   }
 
   interface TypertRemoteScopeMap {
@@ -68,13 +109,19 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
 }
 
 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
 // the listener signature is the owning package's own Cordis declaration.
 function remoteEventContracts(remote: ClientRemote): void {
   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.
   remote.$on('fixture/unselected', () => {})
   // @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
 }
 
 async function benchFiber(
   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()
   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 })
   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', () => {
   it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
@@ -271,6 +686,7 @@ describe('Client Typert API', () => {
     const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
     ctx.typert.contexts.registerClient('fixture', {
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
+      resolve: id => id === 'agent-2' ? agentCtx : undefined,
     })
     const assembly = ctx.plugin(Object.assign(
       (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
     ctx.typert.contexts.registerClient('fixture', {
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
+      resolve: id => id === 'agent-2' ? agentCtx : undefined,
     })
     const assembly = ctx.plugin(Object.assign(
       (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
     ctx.typert.contexts.registerClient('fixture', {
       identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
+      resolve: id => id === 'agent-remounted' ? agentCtx : undefined,
     })
     const direct = directDescriptor()
     const context = contextDescriptor()
@@ -432,6 +850,42 @@ describe('Client Typert API', () => {
     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 () => {
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     const disposeContext = await ctx.remote.$mount({
@@ -579,7 +1033,7 @@ describe('Client Typert API', () => {
     })).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']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
     const ctx = await bench(call)
@@ -599,7 +1053,7 @@ describe('Client Typert API', () => {
     await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
       .rejects.toThrow('expected 2 business argument(s)')
     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'
     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()
   })
 
+  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 () => {
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     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 () => {
-    const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
+    const { ctx, client, carrier } = await eventBench()
     const seen: string[] = []
     const subscriber = ctx.plugin(Object.assign(
       (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
@@ -812,103 +1288,1098 @@ describe('Client Typert API', () => {
     ))
     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()
-    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'])
 
     await client.dispose()
     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 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) })
     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 {
       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[] = []
-    // 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) })
-    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(
-        '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 {
       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[] = []
-    // 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[] = []
     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([])
+    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()
   })
 
+  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 () => {
     const ctx = new Context()
     await ctx.plugin(TypertRegistry)
@@ -1228,6 +1278,17 @@ function rawConnection(ctx: Context): FakeConnectionService {
   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> {
   return ctx.typert.register({
     package: '@fixture/gateway',
@@ -1256,6 +1317,7 @@ function contextProvider(context: Context) {
   return {
     wire: 'agentId',
     wireTypeSymbol: '@fixture/domain#AgentId',
+    identity: (candidate: Context) => candidate === context ? 'agent-1' : 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 type { Scoped } from '@deepseek-ai/dsh-scope'
 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). */
 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 type { Scoped } from '@deepseek-ai/dsh-scope'
 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 './types.ts'
@@ -23,16 +23,6 @@ export * from './model-selection.ts'
 export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } 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' {
   interface Context {
     agents: AgentRegistry
@@ -276,6 +266,7 @@ export class AgentRegistry extends Service {
       typeCtx.typert.contexts.registerHost('agent', {
         wire: 'agentId',
         wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
+        identity: candidate => candidate.agent?.id,
         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 { Scoped } from '@deepseek-ai/dsh-scope'
 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'
 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'
 declare module '@deepseek-ai/dsh-system-prompt' {
   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`. */
 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
    * 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.
    * @param cause - the stable caller intent carried by the active operation signal.
    * @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
    * follows replacement work started before the observed driver retires,
    * but does not identify the settlement of any particular message.
    * @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
    * synchronously after claiming that phase; later waking input remains in the
    * 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.
    * @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.
    * Waking input submitted after active cancellation is queued for the next
    * 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 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
    * sole ordinary message of its own turn.
    * @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;
    * a running driver consumes it at its next step boundary.
    * A rejected step leaves steering parked in the inbox until the next
    * wake; cancellation or disposal may discard pending steering.
    * @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
    * driver. A running driver claims it at the nearest later step boundary;
    * 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.
    * @param message - identified injected context and the source that supplied it.
    */
-  inject(message: UserMessage): void
+    inject(message: UserMessage): void
+  }
 }
 
 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 { 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. */
 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 ctx.plugin(TypertRegistry)
     const agent = stubAgent('remote-agent')
+    Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) })
     const disposeAgent = ctx.agents.register(agent)
 
     const lookup = ctx.typert.lookups.get('agent')
@@ -159,7 +160,10 @@ describe('AgentRegistry', () => {
       wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
     })
     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()
     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 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'
-
-/** 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. */
 export interface BootPayload {
@@ -46,6 +31,58 @@ interface PendingUnary {
   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
  * 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 {
   private readonly worker: Worker
   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.
    *
@@ -82,10 +120,10 @@ export class WorkerTunnel {
    * page console but not the frames. Warning here separates the two without
    * 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. */
-  private readonly releases = new Map<number, () => void>()
+  private readonly releases = new Map<TunnelRequestId, () => void>()
 
   /**
    * Attach to a spawned worker and start consuming response frames.
@@ -102,8 +140,14 @@ export class WorkerTunnel {
       this.inFlight.clear()
       for (const pending of this.unary.values()) pending.reject(reason)
       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()
       this.releases.clear()
     })
@@ -145,13 +189,60 @@ export class WorkerTunnel {
       const settled = await Promise.race([response, raced.rejected])
       // 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.
-      if (this.streams.has(id)) this.observeStreamAbort(id, signal)
+      if (this.bodyStreams.has(id)) this.observeStreamAbort(id, signal)
       return settled
     } finally {
       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).
    * @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 => {}
     const rejected = new Promise<never>((_resolve, reject) => {
       const fail = (): void => { reject(this.abortRequest(id)) }
@@ -220,14 +311,13 @@ export class WorkerTunnel {
    * @param id - request id being abandoned.
    * @returns The abort error the caller surfaces.
    */
-  private abortRequest(id: number): DOMException {
+  private abortRequest(id: TunnelRequestId): DOMException {
     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.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')
     controller?.error(reason)
     return reason
@@ -240,26 +330,35 @@ export class WorkerTunnel {
    * @param id - request id whose body is still crossing.
    * @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) }
     signal.addEventListener('abort', onAbort, { once: true })
     this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) })
   }
 
   /** 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)
     this.releases.delete(id)
     release?.()
   }
 
   /** 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.streams.delete(id)
+    this.bodyStreams.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 }
-    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 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}`)
   }
 
@@ -297,7 +396,7 @@ export class WorkerTunnel {
         this.unary.delete(frame.id)
         const stream = new ReadableStream<Uint8Array>({
           start: (controller) => {
-            this.streams.set(frame.id, controller)
+            this.bodyStreams.set(frame.id, controller)
           },
           cancel: () => {
             this.cancelStream(frame.id)
@@ -307,13 +406,13 @@ export class WorkerTunnel {
         return
       }
       case 'res-chunk': {
-        this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
+        this.bodyStreams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
         return
       }
       case 'res-end': {
-        const controller = this.streams.get(frame.id)
+        const controller = this.bodyStreams.get(frame.id)
         if (controller === undefined) return
-        this.streams.delete(frame.id)
+        this.bodyStreams.delete(frame.id)
         this.inFlight.delete(frame.id)
         this.releaseSignal(frame.id)
         controller.close()
@@ -329,13 +428,19 @@ export class WorkerTunnel {
           pending.reject(reason)
           return
         }
-        const controller = this.streams.get(frame.id)
+        const controller = this.bodyStreams.get(frame.id)
         if (controller === undefined) return
-        this.streams.delete(frame.id)
+        this.bodyStreams.delete(frame.id)
         this.releaseSignal(frame.id)
         controller.error(reason)
         return
       }
+      case 'stream-item':
+      case 'stream-end':
+      case 'stream-error': {
+        this.logicalStreams.get(frame.id)?.push(frame)
+        return
+      }
       default: {
         const unknown: never = frame
         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__?: {
     createApiClient: () => WorkerApiClient
     fetch: TunnelFetch
+    openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>
     loadBundle: (url: string) => Promise<void>
     /** The page spawned the worker the Host runs in, so the page owns it. */
     ownsHost: boolean
@@ -84,6 +85,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
     ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
       createApiClient: () => new WorkerApiClient(tunnel),
       fetch: (input, init) => tunnel.fetch(input, init),
+      openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal),
       loadBundle: (url: string) => tunnel.loadBundle(url),
       // The host lives in a worker this page spawned: the page owns it, so
       // 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 TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame,
   type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame,
+  type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame,
+  type TunnelStreamOpenFrame,
 } from './transport/frames.ts'
 export {
   DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader,
@@ -23,7 +25,7 @@ export {
 } from './transport/synthetic-http.ts'
 export { lowerModuleSource, type LoweredModule } from './compile/transform.ts'
 export {
-  API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer,
+  API_PREFIX, SYNTHETIC_HOST, TunnelServer,
   type TunnelPort, type TunnelSeams, type TunnelServerOptions,
 } from './transport/tunnel.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
 }
 
+/** 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. */
 export interface TunnelAbortFrame {
   readonly t: 'abort'
@@ -34,7 +42,11 @@ export interface TunnelInitFrame {
 }
 
 /** 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. */
 export interface TunnelResponseFrame {
@@ -75,6 +87,36 @@ export interface TunnelResponseErrorFrame {
   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. */
 export type TunnelOutboundFrame =
   | TunnelResponseFrame
@@ -82,6 +124,9 @@ export type TunnelOutboundFrame =
   | TunnelResponseChunkFrame
   | TunnelResponseEndFrame
   | TunnelResponseErrorFrame
+  | TunnelStreamItemFrame
+  | TunnelStreamEndFrame
+  | TunnelStreamErrorFrame
 
 /**
  * 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)}`)
   }
   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 (typeof frame.method !== 'string' || typeof frame.url !== 'string') {
     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,
  *   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
  *   `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
@@ -21,14 +19,12 @@
  */
 import {
   parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId,
+  type TunnelStreamOpenFrame,
 } from './frames.ts'
 import {
   createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange,
 } 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. */
 export const API_PREFIX = '/api'
 
@@ -91,12 +87,24 @@ export interface TunnelPort {
 /** What the tunnel gains once the host tree is up. */
 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>
   /** Boot payload for `GET /__boot__`: the structured index injection table. */
   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}. */
@@ -125,6 +133,8 @@ interface InFlight {
   abort(): void
 }
 
+type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame
+
 /** Recorded response frames, so a 403 from the route lane can be discarded. */
 class BufferedSink {
   private readonly calls: Array<() => void> = []
@@ -171,7 +181,7 @@ export class TunnelServer {
   private readonly requestListener: () => Promise<RequestListener>
   private readonly privilegedMethods: ReadonlySet<string> | undefined
   private readonly unaryApiLane: 'route' | 'direct'
-  private readonly queue: TunnelRequestFrame[] = []
+  private readonly queue: QueuedFrame[] = []
   private readonly inFlight = new Map<TunnelRequestId, InFlight>()
   private seams: TunnelSeams | undefined
   private failure: string | undefined
@@ -209,7 +219,7 @@ export class TunnelServer {
       this.queue.push(frame)
       return
     }
-    void this.serveRequest(frame)
+    this.dispatchFrame(frame)
   }
 
   /**
@@ -219,7 +229,7 @@ export class TunnelServer {
   serve(seams: TunnelSeams): void {
     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)})`)
-    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)
   }
 
-  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))
     this.send({
       t: 'res',
@@ -249,6 +267,40 @@ export class TunnelServer {
     }, [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 {
     const send = this.send.bind(this)
     const inFlight = this.inFlight
@@ -291,7 +343,6 @@ export class TunnelServer {
     try {
       const { frame: routed, path } = this.pathFrame(frame)
       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 }
       this.dispatch(routed, sink)
     } 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
  */
 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 { dirname, join } from './module-system/posix-path.ts'
 import { installProcessGlobal } from './node/globals/process.ts'
@@ -237,6 +238,10 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
 
       const apiProxy = ctx.get('apiProxy')
       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 {
         toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
       }
@@ -248,6 +253,8 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
       tunnel.serve({
         directFetch: (request: Request) => handler.fetch(request),
         bootPayload: () => readBootPayload(ctx),
+        openStream: typertGateway.wireStream.open,
+        streamFailure: typertGateway.wireStream.failure,
       })
     } catch (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) }
 ;(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. */
-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 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 }) },
   } 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.
@@ -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.
   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 { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
 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 {} from '@deepseek-ai/dsh-system-prompt'
 
@@ -18,44 +17,10 @@ declare module '@deepseek-ai/cordis' {
   interface Context {
     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' {
   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,
      * 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 type { ApprovalOutcome } from './types.ts'
+import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts'
 
 export { ApprovalRequestId } 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
  * 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
    * 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 { 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`.
@@ -27,3 +30,61 @@ export function ApprovalRequestId(id: string): ApprovalRequestId {
  * request, or unavailable answerer. Callers fail closed on `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 {
   AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
@@ -25,7 +27,7 @@ export type {
 } from './types.ts'
 
 /** Request for a human answer. */
-export interface AskUserQuestionRequest {
+export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {
   /** Questions to display. */
   questions: AskUserQuestionItem[]
   /** 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. */
 export interface AskUserQuestionOption {
@@ -64,3 +62,29 @@ export interface AskUserQuestionAnswer {
   /** Structured answers keyed by question id. */
   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,
     method: ts.MethodDeclaration,
     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 },
   ): InvocationModel {
     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 {
       id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
       service: binding.service,
       namespace: binding.namespace,
       method: exportedMethod,
       ...(exportedMethod === methodName ? {} : { implementation: methodName }),
+      ...(mode === undefined ? {} : { mode }),
       invocation: receiver,
       ...(scope === undefined ? {} : { scope }),
       parameters,
@@ -1215,11 +1217,11 @@ class FaceAnalyzer {
   private remoteMarker(
     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 }
     | undefined {
     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 }
       | undefined
     for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
@@ -1229,12 +1231,28 @@ class FaceAnalyzer {
         marker = { kind: 'direct' }
       } else if (ts.isCallExpression(expression)
         && 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)
         && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) {
         if (expression.arguments.length < 1 || expression.arguments.length > 2) {
@@ -1259,16 +1277,27 @@ class FaceAnalyzer {
     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')
-    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 {

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

@@ -282,6 +282,7 @@ export class FaceModelEmitter {
     if (invocation.implementation !== undefined) {
       lines.push(`  implementation: ${quote(invocation.implementation)},`)
     }
+    if (invocation.mode !== undefined) lines.push(`  mode: ${quote(invocation.mode)},`)
     if (invocation.invocation.kind === 'direct') {
       lines.push('  invocation: { kind: \'direct\' },')
     } else {
@@ -467,9 +468,11 @@ export class FaceModelEmitter {
       `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
     if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
     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}>>`
   }
 }

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

@@ -131,6 +131,7 @@ export interface InvocationModel {
   readonly namespace: string
   readonly method: string
   readonly implementation?: string
+  readonly mode?: 'stream'
   readonly invocation:
     | { 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 {
     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 {

+ 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>,
   ): void
 
-  export function Remote(exportName: string):
+  export function Remote(option: string | { readonly mode: 'stream' }):
   <This extends object, Args extends unknown[], Result>(
     method: (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 {
   readonly id: string
+  readonly mode?: 'stream'
   readonly cancellation?: { readonly parameter: 'signal' }
   readonly parameters: readonly {
     readonly wire: string
@@ -66,7 +67,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
 
     const model = remotePackage(fixtureRoot)
     expect(model.services).toEqual([])
-    expect(model.invocations).toHaveLength(2)
+    expect(model.invocations).toHaveLength(3)
     expect(model.invocations[0]).toMatchObject({
       id: '@fixture/remote#goals/create',
       service: 'goals',
@@ -111,6 +112,22 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
       }],
       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?.remote?.dts).toContain(
@@ -124,6 +141,9 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
     expect(artifact?.remote?.dts).toContain(
       "'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
     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?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
     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
     expect(declarationMap).toMatchObject({
@@ -241,17 +262,15 @@ export type GenericResult = {
         '  RenameGoalResult,\n  GenericRequest,\n  GenericResult,\n',
       )
       .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 {
     if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
     return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
   }
-}`,
+
+  @Remote({ mode: 'stream' })
+  async *watch`,
       ))
 
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
@@ -292,16 +311,14 @@ export interface BoxPayload {
         '  RenameGoalResult,\n  Box,\n  BoxPayload,\n',
       )
       .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> {
     return request
   }
-}`,
+
+  @Remote({ mode: 'stream' })
+  async *watch`,
       ))
 
     const [artifact] = new WorkspaceTypertGenerator(root).generate()
@@ -313,16 +330,14 @@ export interface BoxPayload {
   it('quotes aliased methods in generated namespace interfaces', () => {
     const root = copyFixture()
     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 {
     return { ref: request.title }
   }
-}`,
+
+  @Remote({ mode: 'stream' })
+  async *watch`,
     ))
 
     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', () => {
     const root = copyFixture()
     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}
 
 /** @typert schema */

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

@@ -5,7 +5,7 @@
  */
 
 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_$.-]+$/
 
@@ -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 {
   InvocationDescriptor,
   InvocationParameterDescriptor,
   InvocationSourceLocation,
   RemoteFailure,
   RemoteResult,
+  TypertClientEventListener,
   TypertClientRemote,
-  TypertClientContextBinder,
+  TypertClientContextAdapter,
   TypertCodec,
   TypertContext,
+  TypertContextAdapter,
   TypertContextMap,
   TypertContextRegistry,
   TypertContextWire,
   TypertDisposer,
   TypertForwardableEvent,
-  TypertHostContextProvider,
+  TypertForwardableEventEntry,
+  TypertHostContextAdapter,
+  TypertHostContextIdentity,
   TypertHostContextResolver,
   TypertLocalRegistry,
   TypertLookup,
@@ -103,9 +123,17 @@ export interface RemoteMethodMarker {
   readonly method: string
   /** Endpoint method when it differs from the implementation member. */
   readonly exportName?: string
+  /** Stream methods yield many independently validated result items. */
+  readonly mode?: 'stream'
   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>(
   method: (this: This, ...args: Args) => Result,
   context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
@@ -120,6 +148,7 @@ interface RemoteInitializerContext<This extends object> {
 
 interface StoredRemoteMethodMarker {
   readonly exportName?: string
+  readonly mode?: 'stream'
   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>,
 ): 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.
  */
-export function Remote(exportName: string): RemoteMethodDecorator
+export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator
 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>,
 ): 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')
   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.
  * @param key - scope key declared through the Context map.
@@ -207,12 +252,7 @@ export function RemoteScope(
 ): RemoteMethodDecorator {
   validateName('Scope key', key)
   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>(
   context: RemoteInitializerContext<This>,
   invocation: RemoteInvocationMarker,
+  mode?: 'stream',
   exportName?: string,
 ): void {
   if (context.private || context.static || typeof context.name !== 'string') {
@@ -241,7 +282,7 @@ function addMarkerInitializer<This extends object>(
     if (prototype === null) {
       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,
   method: string,
   invocation: RemoteInvocationMarker,
+  mode?: 'stream',
   exportName?: string,
 ): void {
   let table = markers.get(prototype)
@@ -258,11 +300,14 @@ function mark(
   }
   const marker: StoredRemoteMethodMarker = {
     ...(exportName === undefined || exportName === method ? {} : { exportName }),
+    ...(mode === undefined ? {} : { mode }),
     invocation: Object.freeze(invocation),
   }
   const current = table.get(method)
   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`)
   }
   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
  * carrier failures into the error branch, so no consumer wraps a call to
  * 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.
  */
 export type RemoteResult<T> =
@@ -64,23 +64,92 @@ export type RemoteResult<T> =
 /** Merge-extensible scoped Remote method signatures generated for consumers. */
 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 = {
-  [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
 }[keyof Events]
 
 /** Merge-extensible forwarding selection declared once by the Host assembly. */
 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.
@@ -181,6 +250,8 @@ export interface InvocationDescriptor {
   readonly method: string
   /** Service member invoked when the exported method name is an alias. */
   readonly implementation?: string
+  /** Absent for unary calls; stream calls validate and deliver every yielded item. */
+  readonly mode?: 'stream'
   /** Receiver selection mode. */
   readonly invocation:
     | { readonly kind: 'direct' }
@@ -192,7 +263,7 @@ export interface InvocationDescriptor {
     }
   /** Optional consuming-Context projection for one direct lookup parameter. */
   readonly scope?: {
-    /** Context kind whose Client binder supplies the identity. */
+    /** Context kind whose Client adapter supplies the identity. */
     readonly context: string
     /** Lookup parameter wire field replaced by the Context identity. */
     readonly wire: string
@@ -204,7 +275,7 @@ export interface InvocationDescriptor {
     /** Reserved final Host method parameter. */
     readonly parameter: 'signal'
   }
-  /** Codec for the resolved method result. */
+  /** Codec for the unary result or each yielded stream item. */
   readonly result: TypertCodec
   /** Source declaration used only for diagnostics. */
   readonly sourceLocation?: InvocationSourceLocation
@@ -227,26 +298,15 @@ export interface TypertClientRemote extends TypertRemoteNamespaceMap {
    */
   $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.
    * @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.
    */
-  $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
 }
 
-/** 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.
-   * @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>
 }
 
-/** 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> = (
   id: Wire,
 ) => 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
+  /**
+   * 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. */
@@ -423,20 +508,20 @@ export interface TypertLookupRegistry {
   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 {
   /**
-   * Register a Host Context resolver.
+   * Register a Host Context adapter.
    * @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>>(
     key: K,
-    provider: TypertHostContextProvider<TypertContextWire<TypertContextMap[K]>>,
+    adapter: TypertHostContextAdapter<TypertContextWire<TypertContextMap[K]>>,
   ): 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.
    * @param key - merge-declared Context 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]>>,
   ): TypertDisposer
   /**
-   * Register a Client Context identity binder.
+   * Register a Client Context adapter.
    * @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>>(
     key: K,
-    binder: TypertClientContextBinder<TypertContextWire<TypertContextMap[K]>>,
+    adapter: TypertClientContextAdapter<TypertContextWire<TypertContextMap[K]>>,
   ): 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.
-   * @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.
-   * @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.
    * @returns disposer for this subscription.
    */

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

@@ -8,11 +8,25 @@ import {
   Remote,
   RemoteScope,
   remoteMethods,
+  type TypertClientEventListener,
   type TypertContext,
   type TypertForwardableEvent,
+  type TypertForwardableEventEntry,
+  type TypertLookup,
   type TypertRemoteEvent,
 } 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' {
   interface Events {
     /**
@@ -25,6 +39,17 @@ declare module '@deepseek-ai/cordis' {
      * @param value - marker payload.
      */
     '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.
      * @param value - marker payload.
@@ -35,12 +60,16 @@ declare module '@deepseek-ai/cordis' {
 }
 
 declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface TypertLookupMap {
+    metaFixture: TypertLookup<MetaFixtureSubject, string>
+  }
+
   interface TypertContextMap {
     metaFixture: TypertContext<string>
   }
 
   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', () => {
@@ -55,6 +84,11 @@ describe('typert-protocol Remote declarations', () => {
         return value
       }
 
+      @Remote({ mode: 'stream' })
+      *watch(): Iterable<string> {
+        yield 'value'
+      }
+
       @RemoteScope('metaFixture')
       scoped(value: string): string {
         return value
@@ -78,6 +112,7 @@ describe('typert-protocol Remote declarations', () => {
     })
     expect(remoteMethods(goals)).toEqual([
       { method: 'create', invocation: { kind: 'direct' } },
+      { method: 'watch', mode: 'stream', invocation: { kind: 'direct' } },
       { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
     ])
     await ctx.fiber.dispose()
@@ -192,6 +227,8 @@ describe('typert-protocol Remote declarations', () => {
     expect(() => Remote('bad name')).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('metaFixture', 'bad/name')).toThrow('export name')
 
@@ -213,6 +250,12 @@ describe('typert-protocol Remote declarations', () => {
     Reflect.setPrototypeOf(prototypeLess, null)
     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 {
       run(): void {}
     }
@@ -236,14 +279,41 @@ describe('typert-protocol Remote declarations', () => {
     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/waterfall'>().toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypertForwardableEvent>()
     expectTypeOf<'meta-fixture/answered'>().not.toExtend<TypertForwardableEvent>()
 
     expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypertRemoteEvent>()
+    expectTypeOf<'meta-fixture/waterfall'>().toExtend<TypertRemoteEvent>()
     expectTypeOf<'meta-fixture/scoped'>().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 type {
   InvocationDescriptor,
-  TypertClientContextBinder,
+  TypertClientContextAdapter,
   TypertContextMap,
   TypertContextRegistry,
   TypertContextWire,
   TypertDisposer,
-  TypertHostContextProvider,
+  TypertHostContextAdapter,
   TypertHostContextResolver,
   TypertLocalRegistry,
   TypertLookupHost,
@@ -334,9 +334,9 @@ function lookupDefinitionEquals(left: TypertLookupDefinition, right: TypertLooku
 }
 
 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 clients = new Map<string, ProviderEntry<TypertClientContextBinder>>()
+  private readonly clients = new Map<string, ProviderEntry<TypertClientContextAdapter>>()
   private readonly changes: ChangeSource
 
   constructor(report: ReportObserverError) {
@@ -347,34 +347,51 @@ class ContextStore {
     return {
       registerHost: <K extends Extract<keyof TypertContextMap, string>>(
         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>>(
         key: K,
         resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
       ) => this.configureHost(ctx, key, resolver),
       registerClient: <K extends Extract<keyof TypertContextMap, string>>(
         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),
       getClient: key => this.clients.get(key)?.provider,
       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
-    if (resolver === undefined) return provider
+    if (resolver === undefined) return adapter
     return {
-      wire: provider.wire,
-      wireTypeSymbol: provider.wireTypeSymbol,
+      wire: adapter.wire,
+      wireTypeSymbol: adapter.wireTypeSymbol,
+      identity: context => adapter.identity(context),
       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>(
     ctx: Context,
     key: string,
@@ -399,16 +416,16 @@ class ContextStore {
     }, `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)
-    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)
-    return this.registerProvider(ctx, this.clients, 'client-context', key, binder)
+    return this.registerProvider(ctx, this.clients, 'client-context', key, adapter)
   }
 
   private registerProvider<Provider>(
@@ -484,7 +501,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract {
     return this.lookupStore.view(this.ctx)
   }
 
-  /** Host Context providers and Client Context binders. */
+  /** Host and Client Context adapters. */
   get contexts(): TypertContextRegistry {
     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 {
     registryFixture: TypertContext<string>
+    registryFixtureOther: TypertContext<string>
   }
 }
 
@@ -331,10 +332,12 @@ describe('TypertRegistry', () => {
     const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
       wire: 'agentId',
       wireTypeSymbol: '@fixture/session#SessionId',
+      identity: candidate => candidate === scoped ? object.id : undefined,
       resolve: id => id === object.id ? scoped : undefined,
     })
     const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
       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)
@@ -345,8 +348,15 @@ describe('TypertRegistry', () => {
       hostTypeSymbol: '@fixture/agent#Agent',
       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.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()])
     expect(ctx.typert.lookups.keys()).toEqual([])
@@ -355,6 +365,26 @@ describe('TypertRegistry', () => {
     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 () => {
     const ctx = await makeCtx()
     const fallback = { id: 'fallback' }
@@ -400,9 +430,11 @@ describe('TypertRegistry', () => {
     const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', {
       wire: 'agentId',
       wireTypeSymbol: '@fixture/session#SessionId',
+      identity: candidate => candidate === fallback ? 'fallback' : undefined,
       resolve: id => id === 'fallback' ? fallback : undefined,
     })
     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')
 
     await disposeProvider()
@@ -410,6 +442,7 @@ describe('TypertRegistry', () => {
     const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', {
       wire: 'agentId',
       wireTypeSymbol: '@fixture/session#SessionId',
+      identity: candidate => candidate === fallback ? 'fallback' : undefined,
       resolve: id => id === 'fallback' ? fallback : undefined,
     })
     await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
@@ -438,9 +471,13 @@ describe('TypertRegistry', () => {
     const host = {
       wire: 'agentId',
       wireTypeSymbol: '@fixture#AgentId',
+      identity: (_candidate: Context) => undefined,
+      resolve: () => undefined,
+    }
+    const client = {
+      identity: (_candidate: Context) => undefined,
       resolve: () => undefined,
     }
-    const client = { identity: () => undefined }
     const disposeLookup = ctx.typert.lookups.register('fixture', lookup)
     const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host)
     const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client)