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ódulo | Responsabilidade |
|---|---|
room | Segredo da sala, codificação do convite, derivação de tópico/chave/id, selagem dos frames. |
proto | Frames do protocolo (hello, msg, ack), limites de tamanho, validação de nomes, ids. |
store | Pasta do perfil: identidade, membros conhecidos, inbox, outbox, log e trava. |
net | Cria o endpoint iroh na infraestrutura pública (ou com PAPO_RELAY). |
node | O membro da sala: gossip, reconexão, entrega com confirmação, presença. |
mcp | Servidor MCP via stdio: ferramentas, push por channels, wait com long poll. |
main | Comandos 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
helloe 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”
waiteinboxtiram as mensagens do inbox (e gravam o inbox no disco).- Responder com
reply_totira 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,pingetools/listrespondem na hora. Cadatools/callvira uma tarefa própria, guardada num mapa para poder ser cancelada pornotifications/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/initializede 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 comonotifications/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 comto, 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.