Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Arquitetura

O papo é um crate Rust com uma biblioteca (src/lib.rs) e um binário (src/main.rs). Cada pessoa roda o próprio papo mcp dentro do Claude Code; os dois servidores se encontram direto pela rede, e ninguém hospeda nada no meio.

flowchart LR
  subgraph maquinaA["Sua máquina"]
    CA["Claude Code"] <-->|"MCP via stdio"| PA["papo mcp"]
    PA --- SA[("~/.papo<br/>inbox, outbox, log")]
    KA(["você: papo log -f / papo say"]) -.-> SA
  end
  subgraph maquinaB["Máquina do colega"]
    PB["papo mcp"] <-->|"MCP via stdio"| CB["Claude Code"]
    PB --- SB[("~/.papo<br/>inbox, outbox, log")]
  end
  PA <==>|"QUIC P2P, frames cifrados com a chave da sala"| PB
  PA -.->|"só se o hole punching falhar"| R(["relay iroh"])
  R -.-> PB
  D[("DNS / pkarr da n0")] -. "acha o endereço pelo id" .- PA
  D -. "acha o endereço pelo id" .- PB

Os módulos têm responsabilidades estreitas e dependem uns dos outros numa ordem só:

flowchart LR
  room --> proto --> store --> node --> mcp --> main["main (CLI)"]
  net --> node
  net -. "testes trocam por rede local" .- T(["tests/"])
MóduloResponsabilidade
roomSegredo da sala, codificação do convite, derivação de tópico/chave/id, selagem dos frames.
protoFrames do protocolo (hello, msg, ack), limites de tamanho, validação de nomes, ids.
storePasta do perfil: identidade, membros conhecidos, inbox, outbox, log e trava.
netCria o endpoint iroh na infraestrutura pública (ou com PAPO_RELAY).
nodeO membro da sala: gossip, reconexão, entrega com confirmação, presença.
mcpServidor MCP via stdio: ferramentas, push por channels, wait com long poll.
mainComandos da CLI (texto para pessoas em português).

O nó (node)

Node::spawn recebe um endpoint iroh já criado. Quem chama decide como ele chega à rede: o binário usa a infraestrutura pública da n0; os testes usam um relay local e uma tabela de endereços em memória. Isso deixa o nó inteiro testável sem internet.

Dentro do nó rodam duas tarefas:

  • Laço de eventos: consome o fluxo do gossip (vizinho entrou, vizinho saiu, mensagem recebida) e trata cada frame.
  • Laço de manutenção: a cada segundo, verifica se o nó está sozinho. Se estiver, disca os membros conhecidos com intervalo crescente (1, 2, 4, 8, 10, 10… segundos). Se estiver conectado, manda um hello e difunde a fila a cada 30 segundos.

O estado (membros, vizinhos, inbox, outbox, ids vistos) fica num Mutex que nunca é segurado durante um await. Cada mudança no inbox ou na fila é gravada no disco na hora.

Reconexão: o papo disca antes do gossip

Os membros não são passados ao gossip como pontos de entrada. O próprio papo disca cada membro com o ALPN do gossip, entrega a conexão pronta ao gossip como se tivesse sido recebida, e só então pede ao gossip para entrar em contato com aquele membro.

stateDiagram-v2
  [*] --> Sozinho: nó sobe (sem bootstrap no gossip)
  Sozinho --> Discando: chegou a hora da próxima tentativa
  Discando --> Sozinho: falhou ou deu timeout (15 s)<br/>espera 1, 2, 4, 8, 10, 10... s
  Discando --> Conectado: conexão aberta, entregue ao gossip<br/>e join_peers com aquele membro
  Conectado --> Conectado: a cada 30 s, hello e reenvio da outbox
  Conectado --> Sozinho: último vizinho caiu (backoff volta a 1 s)

O motivo é um comportamento do iroh-gossip 0.101: se a primeira discagem para um ponto de entrada falha, por exemplo porque o colega abriu o Claude um segundo antes e ainda não publicou o endereço, aquele par fica “pendente” para sempre e novas tentativas não discam de novo. Uma conexão recebida destrava esse estado, e o protocolo do gossip é simétrico. Detalhes na ADR 0003.

Entrega

sequenceDiagram
  participant CV as Seu Claude
  participant NV as Seu papo
  participant NC as papo do colega
  participant CC as Claude do colega
  CV->>NV: send(mensagem)
  NV->>NV: grava na outbox (antes de difundir)
  alt colega online
    NV->>NC: msg (selada com a chave da sala)
    NC->>NC: descarta se não é para ele ou se é duplicata
    NC->>NC: grava inbox e log
    NC-->>NV: ack
    NV->>NV: tira da outbox, log "delivered"
    NV-->>CV: "Delivered to colega"
    NC->>CC: notifications/claude/channel (push)
  else colega offline
    NV-->>CV: "queued" (sem erro)
    Note over NV: a outbox espera
    NC->>NV: o colega volta: conexão + hello
    NV->>NC: reenvia a outbox
    NC-->>NV: ack
  end

Se o ack não chega a tempo, send devolve “na fila” sem erro. A fila é difundida de novo quando um vizinho se conecta e periodicamente. A ADR 0005 explica as escolhas.

Leitura e “marcar como lido”

  • wait e inbox tiram as mensagens do inbox (e gravam o inbox no disco).
  • Responder com reply_to tira do inbox a mensagem respondida e as anteriores do mesmo remetente, porque responder implica ter visto essas mensagens.
  • O push por channels não marca nada como lido.

O servidor MCP (mcp)

O servidor é escrito à mão sobre JSON-RPC, sem SDK (ADR 0004). A estrutura:

flowchart LR
  stdin(["stdin do Claude Code"]) --> L["Leitor<br/>linha a linha"]
  L -->|"initialize, ping, tools/list"| W
  L -->|"tools/call"| T["tarefa por chamada<br/>(cancelável)"]
  T --> W["Escritor único"]
  N["nó"] -- "eventos: mensagem nova" --> B["Bomba do channel"]
  B -->|"notifications/claude/channel"| W
  T <--> N
  W --> stdout(["stdout = só JSON-RPC"])
  • Leitor: lê o stdin linha a linha e despacha. initialize, ping e tools/list respondem na hora. Cada tools/call vira uma tarefa própria, guardada num mapa para poder ser cancelada por notifications/cancelled.
  • Escritor: uma única tarefa escreve no stdout, a partir de um canal. Assim respostas e notificações nunca se misturam numa linha.
  • Inicialização em segundo plano: o nó (trava do perfil, endpoint, gossip) sobe numa tarefa separada. O initialize é respondido imediatamente, e as ferramentas esperam o nó ficar pronto. Se a inicialização falha, as ferramentas devolvem o erro explicando o que fazer.
  • Bomba do channel: depois de notifications/initialized e do nó pronto, assina os eventos do nó, empurra as mensagens não lidas que já estavam no inbox e passa a empurrar cada mensagem nova como notifications/claude/channel. Um conjunto de ids já empurrados evita repetições.

Quando o stdin fecha (o Claude Code encerrou a sessão), o servidor cancela as chamadas em andamento, fecha o nó (o que avisa os vizinhos) e sai.

A CLI (main)

Os comandos de configuração (new, join, invite, install) só mexem em arquivos. Os comandos que falam com a sala (say, status) sobem um nó descartável: identidade nova a cada execução, sem gravar inbox ou fila, sem confirmar mensagens e sem ser lembrado pelos outros. Isso permite usá-los ao mesmo tempo que o servidor MCP do mesmo perfil, que é dono da identidade de verdade.

log -f lê o log.jsonl periodicamente em vez de usar notificações do sistema de arquivos, o que funciona igual em Linux, macOS e Windows.

Testes

  • Unitários, nos módulos: convite, selagem, frames, nomes, negociação de versão, chaves do meta.
  • tests/node.rs: nós de verdade (endpoints iroh e gossip reais) numa rede local com relay em processo. Cobrem entrega com ack, fila que sobrevive a reinício, mensagens com to, marcação de lido por resposta e a CLI descartável.
  • tests/mcp.rs: dirige o binário compilado pelo stdio, como o Claude Code faz. Inclui um teste ignorado por padrão que põe dois servidores MCP para conversar pela internet real.