# Streaming nativo e tela web limpa

Integre o viewport do navegador por HTTP e WebSocket, ou apresente a tela HTML limpa do mesmo computador.

Página canônica: https://remote-agent-machine.carneiro.ai/docs/streaming

## Escolha como apresentar a tela

A página HTML limpa em /view?computer_id=ID é um cliente pronto para o navegador ou WebView, com login RAM. Uma interface nativa pode consumir diretamente jpeg-websocket-v1 e decodificar JPEG no componente de imagem do aplicativo, sem carregar HTML. As duas opções acompanham o mesmo computador selecionado, não uma gravação histórica da tarefa.

O transporte oferece o viewport do Chromium, sem áudio, WebRTC, vídeo codificado ou desktop completo. Um aplicativo iOS/Android e sua renderização, armazenamento seguro, latência e ciclo de vida precisam de validação própria; este guia não afirma que um app nativo já foi validado.

## Autentique e selecione o computador

Use uma chave runner da própria conta, mantida no host confiável ou armazenamento seguro do app. task_observer não autoriza sessão visual. Descubra computadores por GET /api/computers , escolha explicitamente o campo id e confira disponibilidade. Não envie proprietário, host ou URL de executor; CDP, VNC e o executor ficam privados.

Fora de loopback local, use HTTPS/WSS. A sessão por cookie da tela HTML exige login próprio e origem autorizada; uma chave da API não faz login no WebView e não deve ser injetada no HTML, no link ou na conversa.

## Crie uma sessão de observação

Envie POST https://remote-agent-machine.carneiro.ai/api/visual/sessions com Authorization: Bearer <chave runner>, Content-Type: application/json e X-RAM-Intent: visual-session. O corpo abaixo seleciona explicitamente o computador e começa sem controle humano.

A resposta HTTP 201 contém version, transport, session_id, ticket, websocket_path, subprotocol, ticket_protocol_prefix, mode, viewport, max_fps, connect_expires_in_seconds e session_max_seconds. Uma falha não autoriza assumir que a conexão foi criada.

```json
{
  "computer_id": "SEU_COMPUTADOR",
  "mode": "observe",
  "transport": "jpeg-websocket-v1"
}
```

## Contrato da resposta HTTP 201

A resposta é um objeto JSON, sem envelope session. Todos os campos abaixo estão presentes. mode é observe ou control conforme o pedido; max_fps é inteiro entre 1 e 10, conforme configuração. viewport é um objeto com width e height numéricos inteiros, atualmente 1280 e 720. version é número 1, não string.

session_id é UUID e ticket é uma string aleatória base64url de 43 caracteres, formada por A–Z, a–z, 0–9, _ e -. O ticket não é um identificador de tarefa nem uma chave persistente. O marcador no exemplo representa um valor efêmero da resposta e não é um ticket válido; nunca publique a resposta real integral.

```json
{
  "version": 1,
  "transport": "jpeg-websocket-v1",
  "session_id": "11111111-1111-4111-8111-111111111111",
  "ticket": "<ticket efêmero retornado pelo servidor>",
  "websocket_path": "/api/visual/stream",
  "subprotocol": "ram-jpeg-v1",
  "ticket_protocol_prefix": "ram-ticket.",
  "connect_expires_in_seconds": 60,
  "session_max_seconds": 900,
  "max_fps": 4,
  "viewport": { "width": 1280, "height": 720 },
  "mode": "observe"
}
```

## Conecte o WebSocket com ticket de uso único

Resolva websocket_path na mesma origem configurada e converta HTTPS para WSS; HTTP para WS é somente desenvolvimento em loopback. O caminho atual é /api/visual/stream. Envie os dois subprotocolos ram-jpeg-v1 e ram-ticket.<ticket>. O servidor seleciona apenas ram-jpeg-v1.

O ticket expira em 60 segundos e permite uma tentativa de conexão. Ele vai no subprotocolo, nunca na URL, nos logs ou em armazenamento persistente. A sessão dura no máximo 900 segundos. A chave runner autentica a criação HTTP; não substitui o ticket no handshake.

A biblioteca WebSocket nativa precisa responder aos frames Ping com Pong. O gateway envia Ping a cada dois segundos; bibliotecas como ws respondem automaticamente. Ping/Pong são frames do protocolo WebSocket, não mensagens JSON da aplicação.

## Associe metadados e bytes JPEG

Mensagens de texto são JSON. hello informa versão, transporte, modo, FPS máximo e viewport. Cada frame informa sequence, generation, mime_type, width, height, control, controlling e url; a mensagem binária seguinte contém os bytes JPEG desse frame, sem base64 nem envelope JSON.

Associe cada JPEG ao frame anterior e descarte imagens antigas quando a UI não acompanhar. Use width e height para escala e coordenadas; o viewport padrão é 1280 × 720. generation identifica o computador em execução: se mudar, a sessão termina e o cliente deve voltar a observar com uma conexão nova. Conteúdo de tela e url podem ser privados; não registre os frames indiscriminadamente.

state comunica controle/disponibilidade; ack confirma a sequence de uma entrada processada; error encerra uma situação insegura. controlling só concede autoridade ao socket que recebeu a confirmação, não a outro observador.

```json
{
  "type": "frame",
  "sequence": 1,
  "generation": "GERACAO_DO_EXECUTOR",
  "mime_type": "image/jpeg",
  "width": 1280,
  "height": 720,
  "control": "available",
  "controlling": false,
  "url": "https://example.com/"
}
```

## hello e tipos dos frames

hello tem exatamente os campos do exemplo: type hello, version 1, transport jpeg-websocket-v1, mode observe ou control, max_fps inteiro de 1 a 10 e viewport {width:1280,height:720}. Não traz ticket, session_id ou generation; a geração chega no frame.

No frame, sequence é um contador numérico inteiro positivo do gateway, compartilhado entre capturas e independente das entradas humanas; generation é string UUID do executor. mime_type é image/jpeg; width e height são inteiros 1280 e 720 nesta versão. control do frame aceita available, leased, transition ou human; controlling é boolean e url é string da página, podendo ser about:blank. Não exija URL HTTP(S) para aceitar metadados de frame.

```json
{
  "type": "hello",
  "version": 1,
  "transport": "jpeg-websocket-v1",
  "mode": "control",
  "max_fps": 4,
  "viewport": { "width": 1280, "height": 720 }
}
```

## Variantes de state, ack e error

state não tem um único conjunto de campos obrigatórios. Há quatro variantes atuais, mostradas como mensagens separadas abaixo: transition omite controlling; human inclui controlling true e três contadores inteiros não negativos; released e unavailable incluem controlling false. Os contadores registram processos parados, downloads cancelados e exports abortados. Não presuma que todo state contém controlling, generation ou sequence.

O conjunto de control nas mensagens frame/state é available, leased, transition, human, released ou unavailable. Um state transition só anuncia preparo, não concede controle. Um state unavailable informa falha de captura; um observador pode continuar aberto, enquanto a sessão humana é encerrada. state released confirma devolução; ainda é necessário observar/conferir antes da continuação explícita.

ack contém somente type ack e sequence numérica inteira positiva da entrada concluída. error contém somente type error e message string legível; não há code, retry_after ou enum estável de motivos. Uma mensagem error pode ser seguida de fechamento, e pode nem chegar se o socket estiver congestionado. Trate também close/error da biblioteca.

```json
{ "type": "state", "control": "transition" }

{
  "type": "state", "control": "human", "controlling": true,
  "stopped_processes": 0,
  "cancelled_downloads": 0,
  "aborted_exports": 0
}

{ "type": "state", "control": "released", "controlling": false }

{ "type": "state", "control": "unavailable", "controlling": false }

{ "type": "ack", "sequence": 1 }

{ "type": "error", "message": "Computer restarted; reconnect." }
```

## Exemplo de observação sem HTML

O exemplo usa fetch nativo do Node.js 24 e a biblioteca pública ws. No seu próprio projeto, instale npm install --save-exact ws@8.22.0 e salve o exemplo em um arquivo .mjs; não é necessário acessar o repositório RAM. O app chama observeComputer com baseUrl desta instalação, computerId selecionado, uma função credential que fornece sua chave runner em privado e callbacks renderJpeg/onDisconnect. Nenhum token fictício concede acesso real.

A função cria o ticket por HTTP e abre o WebSocket usando somente o protocolo público. O app implementa o renderizador JPEG e mostra a desconexão. O exemplo não reconecta, assume controle nem reenvia entradas automaticamente.

```js
import { WebSocket } from 'ws';

export async function observeComputer({ baseUrl, computerId, credential, renderJpeg, onDisconnect }) {
  if (!computerId) throw new Error('Selecione um computador da sua conta');
  const origin = new URL(baseUrl);
  if (origin.username || origin.password || origin.search || origin.hash ||
      origin.pathname !== '/' || (origin.protocol !== 'https:' &&
      !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) {
    throw new Error('Use uma origem HTTPS ou loopback local');
  }
  const response = await fetch(new URL('/api/visual/sessions', origin), {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${await credential()}`,
      'Content-Type': 'application/json',
      'X-RAM-Intent': 'visual-session',
    },
    body: JSON.stringify({ computer_id: computerId,
      mode: 'observe', transport: 'jpeg-websocket-v1' }),
  });
  if (response.status !== 201) throw new Error('Não foi possível criar a sessão');
  const session = await response.json();
  const url = new URL(session.websocket_path, origin);
  if (url.origin !== origin.origin || url.search || url.hash ||
      url.pathname !== '/api/visual/stream') throw new Error('Endpoint inválido');
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
  const socket = new WebSocket(url, [
    session.subprotocol,
    session.ticket_protocol_prefix + session.ticket,
  ]);
  socket.binaryType = 'arraybuffer';
  let generation;
  let pendingFrame;
  socket.on('message', (data, binary) => {
    try {
      if (binary) {
        if (!pendingFrame) throw new Error('JPEG sem metadados');
        const jpeg = new Uint8Array(data);
        if (jpeg.byteLength > 2_000_000) throw new Error('Frame excede o limite');
        renderJpeg(jpeg, pendingFrame); // substituir a imagem anterior
        pendingFrame = undefined;
        return;
      }
      const event = JSON.parse(data.toString());
      if (event.type === 'frame') {
        if (typeof event.generation !== 'string' || event.mime_type !== 'image/jpeg' ||
            (generation && generation !== event.generation)) {
          throw new Error('Observe novamente a geração atual');
        }
        generation = event.generation;
        pendingFrame = event;
      }
      if (event.type === 'error') socket.close();
    } catch { socket.close(); }
  });
  socket.on('error', () => socket.close());
  socket.on('close', () => onDisconnect?.());
  // ws responde a Ping com Pong automaticamente; não são mensagens JSON.
  // A UI mostra close/error e oferece reconexão explícita com ticket novo.
  return { close: () => socket.close() };
}
```

## Alterne entre HTML e interface nativa

Para abrir a tela HTML de uma tarefa, obtenha o viewer autenticado por GET /api/runs/{id}/viewer ou MCP run_viewer. Apresente sua url somente quando a pessoa quiser acompanhar. Sem viewer autorizado, informe a indisponibilidade; não fabrique link substituto. A atribuição atual é verificada novamente ao abrir.

Ao trocar de view, feche o socket anterior para evitar sessões duplicadas. Se estiver sob controle humano, devolva o controle e confira sua liberação antes de agir por outro cliente. Abra a nova view para o mesmo computer_id em observe, com ticket novo no modo nativo ou sessão web autenticada no HTML. A troca não transfere cookies, ticket, concessão humana ou sequência de entradas entre clientes e não continua uma tarefa pausada.

Após desconexão ou mudança de geração, apresente Reconectar como decisão explícita, peça novo ticket e observe o estado atual. Nunca reuse o ticket anterior ou repita takeover, clique, texto ou navegação pendentes. Uma resposta perdida pode ter produzido efeito.

## Controle humano é uma etapa separada

Uma sessão observe não pode assumir controle nem enviar input. Para oferecer controle, crie explicitamente uma sessão mode control e espere o primeiro frame. Só um gesto da pessoa envia {"type":"takeover"}; espere state com controlling true antes de habilitar input.

O servidor e o executor validam a concessão, tratam processos/downloads ativos e invalidam o controle anterior do agente. Inputs levam sequence inteira positiva e crescente, distinta da sequência de frames; aguarde ack e não repita uma entrada incerta. {"type":"release"} devolve controle; continuação do runner é outro pedido explícito.

## Envelope de entrada e confirmação

Envie cada entrada como mensagem de texto JSON com somente type, sequence e input. type é input; sequence é um inteiro de 1 a 9007199254740991, estritamente crescente na concessão humana deste socket. Os objetos são fechados: campos extras são recusados. Nos blocos abaixo, cada objeto é uma mensagem separada; o bloco inteiro não é um array nem uma única mensagem JSON. Não envie computer_id, generation, lease_id, proprietário ou segredo no envelope; o gateway usa o vínculo autenticado da sessão.

A resposta {"type":"ack","sequence":1} confirma que a entrada 1 terminou no executor. Ela não comprova conclusão de uma tarefa, login ou efeito empresarial. Aguarde o ack correspondente antes da próxima entrada e confira o frame atual. Cliques e teclas usam duas entradas separadas, down e up, cada uma com sua própria sequence e ack.

Após erro, fechamento ou perda de ack, desabilite entradas e apresente o resultado como incerto. O servidor não reenvia a entrada. Sequência repetida/menor, formato inválido ou concessão ausente/expirada encerram a sessão; não aumente sequence para repetir a mesma intenção. Reabra somente por decisão explícita, com ticket novo, e observe antes de qualquer nova ação.

## Clique e movimento no viewport

input.type pointer aceita phase move, down ou up; x é número de 0 a 1279 e y de 0 a 719. button aceita left, middle ou right, com padrão left quando omitido. Um clique usa down e depois up, na mesma posição, aguardando cada ack. Não existe input.type click.

Converta o gesto na imagem exibida para coordenadas do viewport, descontando margens da imagem: x = (posição horizontal − margem esquerda) × width / largura exibida; y usa height / altura exibida. Ignore gestos fora da imagem. O contrato atual limita o viewport a 1280 × 720; não envie coordenadas da tela física do celular.

```json
{
  "type": "input",
  "sequence": 1,
  "input": {
    "type": "pointer", "phase": "down",
    "x": 420, "y": 180, "button": "left"
  }
}

{
  "type": "input",
  "sequence": 2,
  "input": {
    "type": "pointer", "phase": "up",
    "x": 420, "y": 180, "button": "left"
  }
}
```

## Texto e teclado

input.type text aceita text como string com até 4000 unidades UTF-16 (o comprimento de string em JavaScript), inclusive vazia. Insere texto no elemento atualmente focado; não seleciona um campo nem envia Enter. É indicado para texto/IME. Não registre o conteúdo digitado.

input.type key exige phase down ou up e key: uma string de comprimento 1 em UTF-16, ou exatamente Backspace, Tab, Enter, Escape, Delete, Home, End, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, PageUp, PageDown, Shift, Control, Alt, Meta, F1 a F12. Combinações usam entradas individuais de pressão/liberação dos modificadores e da tecla; não use strings como Control+A. Libere as teclas; cada entrada precisa de sequence nova e ack.

```json
{
  "type": "input", "sequence": 3,
  "input": { "type": "text", "text": "Olá" }
}

{
  "type": "input", "sequence": 4,
  "input": { "type": "key", "phase": "down", "key": "Enter" }
}

{
  "type": "input", "sequence": 5,
  "input": { "type": "key", "phase": "up", "key": "Enter" }
}
```

## Rolagem e navegação

input.type wheel exige delta_x e delta_y numéricos, cada um entre −2000 e 2000. A roda atua na posição atual do ponteiro; posicione-o com pointer/move quando precisar direcionar a rolagem. Não existe input.type scroll.

input.type navigate exige url absoluta HTTP ou HTTPS, com até 4096 caracteres e sem usuário/senha embutidos. O executor espera DOMContentLoaded com limite de 7 segundos; erro ou timeout pode ocorrer depois de iniciar a navegação. Confira o resultado observado sem repetir automaticamente.

```json
{
  "type": "input", "sequence": 6,
  "input": { "type": "wheel", "delta_x": 0, "delta_y": 400 }
}

{
  "type": "input", "sequence": 7,
  "input": { "type": "navigate", "url": "https://example.com/" }
}
```

## Adaptador mínimo para gestos do app

Este trecho complementa a conexão HTTP/WebSocket anterior: socket é um ws já aberto com ticket novo de sessão mode control. Mantenha a associação JSON/JPEG e a conferência de geração do exemplo de observação. O app chama takeover, input e release somente a partir de gestos explícitos da pessoa; não há comando automático ao conectar. O trecho controla a fila de uma entrada e o deadline finito escolhido em waitMs; não implementa renderizador ou app móvel.

state com control human e controlling true habilita entrada; state released com controlling false confirma a devolução. Um frame não transfere autoridade de outro socket. error.message é explicação legível, não um código estável para decidir retry; mesmo sem receber error, close exige nova inspeção.

```js
function bindHumanGestures(socket, onState, waitMs) {
  if (!Number.isInteger(waitMs) || waitMs < 1 || waitMs > 2147483647) throw new Error("Escolha waitMs entre 1 e 2147483647");
  let timer;
  const clearDeadline = () => { clearTimeout(timer); timer = undefined; };
  let observed = false, controlling = false;
  let sequence = 0, pending, transition;
  const send = value => {
    if (socket.readyState !== 1) throw new Error("Socket fechado");
    socket.send(JSON.stringify(value));
  };
  const stop = () => {
    clearDeadline(); controlling = false;
    // Se pending existe, o resultado dessa entrada é incerto.
    onState({ controlling: false, uncertain: pending !== undefined || Boolean(transition) });
  };
  const armDeadline = () => {
    clearDeadline();
    timer = setTimeout(() => { stop(); socket.close(); }, waitMs);
  };
  socket.on("message", (data, binary) => {
    if (binary) return; // JPEG tratado pelo renderizador separado.
    try {
      const event = JSON.parse(data.toString());
      if (event.type === "frame") {
        observed = true;
        if (event.controlling !== true) controlling = false;
      }
      if (event.type === "state") {
        if (event.controlling === true && event.control === "human") {
          if (transition === "takeover" && pending === undefined) {
            clearDeadline();
            sequence = 0; controlling = true; transition = undefined;
          } else if (!controlling) throw new Error("Concessão inesperada");
        } else {
          controlling = false;
          if (event.control === "released" && transition === "release") {
            clearDeadline(); transition = undefined;
          }
        }
      }
      if (event.type === "ack" && event.sequence === pending) {
        clearDeadline(); pending = undefined;
      }
      if (event.type === "error") { stop(); socket.close(); return; }
      onState({ controlling, awaitingAck: pending !== undefined });
    } catch { stop(); socket.close(); }
  });
  socket.on("error", () => { stop(); socket.close(); });
  socket.on("close", stop);
  return {
    takeover() {
      if (!observed || controlling || transition || pending !== undefined) throw new Error("Observe primeiro");
      transition = "takeover"; armDeadline();
      try { send({ type: "takeover" }); }
      catch { stop(); socket.close(); throw new Error("Takeover incerto; confira"); }
    },
    input(value) {
      if (!controlling || pending !== undefined) throw new Error("Aguarde controle e ack");
      pending = ++sequence; armDeadline(); // Sem replay após timeout.
      try { send({ type: "input", sequence, input: value }); }
      catch { stop(); socket.close(); throw new Error("Resultado incerto; confira"); }
    },
    release() {
      if (!controlling || pending !== undefined) throw new Error("Aguarde o ack");
      controlling = false; transition = "release"; armDeadline();
      try { send({ type: "release" }); }
      catch { stop(); socket.close(); throw new Error("Liberação incerta; confira"); }
      // Aguarde state released antes de trocar de cliente.
    }
  };
}
```

## Prazos do servidor e deadlines do cliente

O ticket vence 60 segundos após emissão; o socket tem limite de 900 segundos a partir da conexão. A concessão humana no executor dura até 8 segundos, podendo terminar antes pelo prazo de uso autorizado, e o gateway tenta renová-la no ciclo de 2 segundos. São prazos de validade, não tempos prometidos de resposta.

O heartbeat envia Ping a cada 2 segundos. A conexão é encerrada no próximo ciclo que encontra o Pong anterior ausente. A agenda do event loop e a rede afetam a hora observada; isso não é uma garantia de desconexão em exatamente 2 segundos nem um timer JSON. Responda Pong pela biblioteca WebSocket.

Não existe deadline público ponta a ponta fixo para ack, takeover ou release. As chamadas internas gateway→executor têm timeout padrão de 25 segundos por chamada, e navigate tem 7 segundos no executor; fila, verificações de autorização, medição e limpeza envolvem outras esperas. Esses limites internos não garantem que o cliente receba ack/state nesse prazo.

Escolha no cliente um limite explícito, finito e positivo para criação HTTP, handshake, primeiro frame, ack e confirmação de takeover/release. É uma política do cliente, não campo do protocolo nem SLA do RAM. Ao vencer após envio de ação, desabilite input, encerre a conexão e explique resultado incerto; não retransmita. Confira o estado numa reconexão explícita com ticket novo antes de decidir outra ação. O adaptador de gestos recebe waitMs inteiro escolhido pelo app, entre 1 e 2147483647 milissegundos (limite do timer JavaScript), para cada ack/transição; a conexão/primeiro frame e o fetch também precisam de limites próprios.

## Fechamento e códigos WebSocket

O gateway não define códigos próprios de fechamento por motivo. Seu caminho de falha usa terminate, sem enviar um frame Close explicativo; o cliente pode observar 1006, encerramento anormal, que não é um código transmitido no fio. A biblioteca também pode fechar por violações do protocolo, como 1009 para payload excessivo; proxies e o cliente podem produzir outros códigos.

Não interprete um close code como recibo de release, ausência de efeito, motivo de saldo ou autorização para replay. Nem 1000 comprova que o controle foi liquidado. Uma criação HTTP recusada devolve JSON {error: string}: 400 para pedido inválido, 401 sem login, 403 para acesso/origem recusados, 409 sem computador próprio pronto e 429 por limite de sessões. Handshake não autorizado retorna HTTP 401 e não cria uma sessão aberta. Essas recusas não autorizam retry de uma entrada já enviada em outro socket.

## Limites do transporte atual

O máximo anunciado em max_fps é configurado pelo servidor: padrão 4 FPS, faixa de 1 a 10. JPEG de qualidade 65, até 2.000.000 bytes por frame. Até quatro sockets simultâneos e 32 tickets pendentes por gateway; isso não é uma quota de escala validada para clientes móveis.

Mensagens de controle têm até 16.000 bytes, fila até 16 entradas e limite de 100 mensagens por segundo. O gateway descarta oportunidades de frame com 1.000.000 bytes pendentes e encerra a conexão acima de 4.000.000. O cliente deve manter uma fila de renderização limitada.

A concessão humana expira em oito segundos e é renovada pelo gateway. Perda de heartbeat, expiração da sessão, revogação da chave, mudança de atribuição/geração e falha insegura exigem fechamento e nova inspeção. Não há reconexão com replay.

## Perguntas frequentes

### Preciso carregar HTML para mostrar a tela no app?

Não. O app pode usar HTTP para emitir um ticket e WebSocket para receber JSON e JPEG binário. A tela HTML limpa continua sendo uma alternativa com login próprio.

### Um link viewer ou task_observer autentica o streaming nativo?

Não. A criação da sessão visual exige credencial apropriada da própria conta e computador atribuído. O link viewer exige login web; task_observer não concede acesso visual.

### A troca de interface retoma automaticamente o agente?

Não. Feche a conexão anterior, confirme a liberação do controle e abra a nova view observando. Retomar uma tarefa ou assumir controle exige ação explícita.

## Guias relacionados

- [RAM Android: um dispositivo para seu agente](https://remote-agent-machine.carneiro.ai/docs/android.md)
- [RAM: suíte MCP-native para agentes](https://remote-agent-machine.carneiro.ai/docs.md)
- [Conectar um harness por MCP](https://remote-agent-machine.carneiro.ai/docs/mcp.md)
- [Usar a API HTTP](https://remote-agent-machine.carneiro.ai/docs/http.md)
- [Integrar com JavaScript](https://remote-agent-machine.carneiro.ai/docs/sdk.md)
- [Quando a tarefa precisa de você](https://remote-agent-machine.carneiro.ai/docs/interventions.md)
- [Streaming nativo e tela web limpa](https://remote-agent-machine.carneiro.ai/docs/streaming.md)
- [Permissões, intervenção e resultados incertos](https://remote-agent-machine.carneiro.ai/docs/safety.md)
