SLOPSHOPPER

Habblaud: mensagens pelo escritório

Leva para a sessão do Claude Code as mensagens que você digita no Habblaud (painel do agente ou terminal), como se você as tivesse digitado no terminal.

newnetworktimer
★ 202v0.7.0MITupdated 2026-10-09marmottajr/habblaud/mod/habblaud-mensagens
A shopper browsing a rack in a slop shop
README

<img src="client/public/assets/brand/logo-mark@4x.png" width="96" alt="Logo do Habblaud: um pequeno prédio em pixel art" />

<h1 align="center">Habblaud</h1>

<b>O escritório virtual dos seus agentes do Claude Code (e do Codex).</b><br /> Cada projeto vira uma sala, cada agente vira um personagem em pixel art que mostra, em tempo real, o que está fazendo.

<img alt="Node.js 22.12+" src="https://img.shields.io/badge/node-%E2%89%A522.12-5fa04e?logo=node.js&logoColor=white" /> <img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-7-3178c6?logo=typescript&logoColor=white" /> <img alt="Docker" src="https://img.shields.io/badge/Docker-pronto-2496ed?logo=docker&logoColor=white" /> <img alt="Zero dependências de runtime" src="https://img.shields.io/badge/depend%C3%AAncias%20de%20runtime-0-f08a3c" /> <img alt="Interface em português" src="https://img.shields.io/badge/interface-PT--BR-4aa8e8" />

<img src="docs/screenshots/office.gif" width="820" alt="Animação de uma sala do Habblaud: personagens digitando nas mesas, subagentes com crachá e balões de atividade" />


Você abre o Claude Code em vários projetos, dispara subagentes, deixa tarefas rodando… e perde a noção de quem está fazendo o quê. O Habblaud transforma isso num escritório que dá para entender de relance: quem está digitando, quem levantou a mão porque precisa de você, quem entregou o trabalho e foi embora, quem foi tomar um café enquanto espera a próxima instrução — e quanto de cada conta você já gastou na sessão de 5 horas e na semana.

E quando dois ou mais agentes estão à toa, o escritório ganha vida social: cada um tem personalidade própria, e eles veem futebol juntos no lounge, jogam videogame e ping-pong, fofocam na copa, se arrumam no espelho e apostam moedinhas no jokenpô. Veja em Vida social.

Tudo roda na sua máquina, lendo os arquivos que o próprio Claude Code já grava (e só as linhas alias do seu shell, para dar a letra de cada conta). Nada sai do computador: o Habblaud não lê credenciais nem faz chamadas externas.

Sumário

Como é

Visão geral. O prédio tem recepção com elevadores, copa, banheiros e lounge; cada projeto com uma sessão aberta ganha a sua sala ao longo do corredor. No topo, os contadores e o uso de cada conta; à esquerda, as salas e os agentes; embaixo, o feed de atividade.

Visão geral do Habblaud: prédio com recepção, copa, banheiros, lounge e salas de projeto; barra lateral com salas e agentes; uso das contas no topo; feed de atividade embaixo

<table> <tr> <td width="50%"><img src="docs/screenshots/agent-details.png" alt="Gaveta de detalhes de um agente com tarefas, subagentes, linha do tempo e estatísticas" /></td> <td width="50%"><img src="docs/screenshots/night.png" alt="O escritório à noite, com postes acesos e salas iluminadas" /></td> </tr> <tr> <td><b>Detalhes do agente:</b> atividade atual, tarefas com progresso, subagentes, linha do tempo e estatísticas.</td> <td><b>Dia e noite:</b> o céu nas janelas e a iluminação seguem a hora local.</td> </tr> </table>

<table> <tr> <td width="42%"><img src="docs/screenshots/collab.png" alt="Subagente entregando o resultado ao agente principal enquanto outra agente pede permissão" /></td> <td width="58%"> <b>Colaboração à vista.</b> Subagentes são colegas com nome próprio e crachá: chegam pelo elevador, trabalham na sala do projeto e, ao terminar, vão até o agente que os chamou entregar o resultado (📦) antes de ir embora.<br /><br /> <b>Precisa de você.</b> Quando um agente espera uma permissão ou resposta no terminal, ele corre para a mesa e levanta a mão, com um alerta piscando — e um aviso aparece na tela (opcionalmente com som e notificação do navegador). Dá para aprovar, responder a pergunta ou mandar a próxima instrução dali mesmo. </td> </tr> </table>

<table> <tr> <td width="58%"> <b>Esperando o shell.</b> Quando o agente termina o turno mas deixa um comando rodando (testes, build, deploy…), ele não sai para passear: fica na mesa com uma ampulheta virando sobre a cabeça, o terminal mostrando o progresso e um balão com o comando e o tempo. E a espera vira comédia: primeiro ele come pipoca assistindo ao terminal; depois de 3 min cruza os braços e gira na cadeira; depois de 10 min junta teia de aranha; depois de 25 min cochila. Quando o comando termina, levanta e comemora com confete — ou ganha uma nuvem de chuva, se falhou. Na lista lateral e nos detalhes, um cronômetro mostra há quanto tempo cada comando está rodando. </td> <td width="42%"><img src="docs/screenshots/shell-wait.png" alt="Agente comendo pipoca na mesa, com uma ampulheta sobre a cabeça e o balão 'Rodar a suíte de testes · 1 min'; na fileira da frente, uma colega espera há mais tempo, com teia de aranha na cadeira e o terminal mostrando o progresso" /></td> </tr> </table>

A luz apaga. Quando você fecha a última sessão de um projeto, o último personagem vai até o interruptor, apaga a luz, sai pelo elevador — e a sala é desmontada, virando jardim até um novo projeto chegar. Se o jardim ficou entre duas salas, a sala mais distante se muda para lá: é montada no lugar vago, o pessoal vai andando até ela, e o endereço antigo apaga e é desmontado. Assim o prédio não fica com buracos e encolhe sozinho.

1. A sala é montada, ainda apagada2. Alguém acende a luz e todos trabalham3. O último sai e apaga a luz4. A sala vira jardim
Sala sendo montada, com os móveis aparecendo e a luz apagadaSala acesa com três agentes trabalhando nas mesasSala com a luz apagada e os móveis ainda no lugar, depois que todos saíramO lote da sala transformado em jardim

<table> <tr> <td width="34%"><img src="docs/screenshots/mobile.png" alt="Habblaud no celular, com o uso das contas no topo" /></td> <td> <b>Também no celular.</b> A interface se adapta a telas pequenas: o uso das contas fica no topo, a lista de salas vira uma gaveta e os detalhes abrem de baixo para cima. Veja como liberar o acesso pela rede local em <a href="#abrir-no-celular-opcional">Abrir no celular</a>. </td> </tr> </table>

As imagens acima usam o modo demonstração (projetos, pessoas e contas fictícios).

Instalação

Requisitos

  • macOS (testado) ou Linux.
  • Claude Code instalado, com uma ou mais contas (2.1.287 ou mais novo para o mod).
  • Node.js 22.12+ e npm (desenvolvido com o Node 24; o Docker já usa o Node 24).
  • Docker Desktop (ou Docker Engine com Compose v2), se for rodar em container.

1. Baixe o projeto

git clone https://github.com/marmottajr/habblaud.git
cd habblaud
npm install

O npm install só baixa ferramentas de compilação (Vite, TypeScript, tsx…). O servidor do Habblaud não tem dependências de runtime: usa só módulos nativos do Node.

2. Suba o Habblaud

Opção A — Docker (recomendado para deixar sempre ligado)

npm run docker:up

O script detecta as suas contas do Claude Code, monta só as pastas necessárias (em modo somente leitura), constrói a imagem e sobe o container — que reinicia sozinho junto com o Docker. Abra http://localhost:4747.

Opção B — Node, sem Docker

npm run build
npm start

Abra http://localhost:4747. Para desenvolver, use npm run dev (servidor + Vite com recarga automática).

Agora abra o Claude Code em qualquer projeto e veja o seu agente chegar pelo elevador. 🎉

3. Instale o mod do Habblaud no Claude Code (recomendado)

npm run mod:install                        # o mod e os plugins de permissões e de mensagens, em cada conta
npm run mod:install -- --sem-permissoes    # sem responder permissões e perguntas pelo escritório
npm run mod:install -- --sem-mensagens     # sem mandar mensagens aos agentes pelo escritório

O Habblaud traz um mod (um plugin que roda dentro do Claude Code) e o instala em cada conta, pelo próprio claude plugin, a partir desta pasta. Com ele:

  • o uso de 5 horas e semanal de cada conta aparece ao vivo no Habblaud;
  • o terminal mostra uma linha quando outra sessão precisa de você (permissão ou resposta);
  • o comando /habblaud passa a existir no Claude Code;
  • dá para aprovar ou recusar pelo escritório os pedidos de permissão ("Do you want to…") e responder as perguntas do agente, com o plugin habblaud-permissoes (veja Responder pelo escritório);
  • dá para mandar mensagens ao agente pelo escritório, como se você digitasse no terminal dele, com o plugin habblaud-mensagens (veja Mandar mensagens).

Precisa do Claude Code 2.1.287 ou mais novo (claude --version). O mod roda dentro de cada sessão, com as suas permissões, e acessa só o que está listado em mod/README.md; para conferir sem rodar nada, claude plugin validate mod/habblaud mostra os eventos que ele trata e as chamadas que faz. Sessões já abertas carregam o mod com /reload-plugins (ou ao reabrir). Se a conta tinha o jeito antigo (abaixo), o mod:install tira o tap e o hook do settings.json (com backup), porque o mod faz o mesmo. Confira com npm run mod:status; para desfazer: npm run mod:uninstall.

O mod é lido desta pasta. Se mover a pasta, rode npm run mod:install de novo.

Sem mods, o uso ao vivo e o responder pelo escritório vêm de dois instaladores que editam o settings.json de cada conta (com backup antes):

npm run usage:install    # põe um "tap" na frente do statusline de cada conta, que captura o uso de 5h/semanal
npm run hooks:install    # acrescenta o hook PermissionRequest, para responder permissões pelo escritório

O seu statusline continua aparecendo igual. Para desfazer: npm run usage:uninstall e npm run hooks:uninstall. Os dois apontam para esta pasta: se mover a pasta, rode-os de novo (até lá, o statusline das contas mostra erro). Quando atualizar o Claude Code, troque pelo mod: o npm run mod:install tira o tap e o hook antigos (com backup) para não ficarem dois capturando o uso ou respondendo o mesmo pedido.

4. Codex no escritório (opcional)

Se você também usa o Codex da OpenAI (a CLI codex ou o app), as sessões dele entram no mesmo escritório: cada projeto é uma sala — a mesma sala do Claude Code, quando os dois estão abertos no mesmo projeto — e cada sessão, um personagem com o selo CODEX. O Habblaud acha sozinho a pasta do Codex (~/.codex, CODEX_HOME ou ~/.codex*) e lê as conversas, sem instalar nada. Para ver o Codex ao vivo (o que está fazendo agora, quem precisa de você) e aprovar comandos pelo escritório, instale os hooks:

npm run codex:install      # acrescenta os hooks do Habblaud em ~/.codex/hooks.json (backup antes; os outros ficam)
npm run codex:status       # confere

Depois, abra o Codex e aprove os hooks do Habblaud em /hooks: o Codex só roda um hook novo depois que você o aprova (o Habblaud nunca grava essa aprovação). Com o Habblaud no Docker, para mandar mensagens às sessões do Codex deixe também npm run codex:bridge rodando no Mac (veja Codex).

Abrir no celular (opcional)

Por padrão o Habblaud só aceita conexões do próprio computador. Para abrir no celular (no mesmo Wi-Fi), com Docker:

printf 'HABBLAUD_BIND=0.0.0.0\n' > .env
npm run docker:up -- --no-build

Depois abra http://<ip-do-computador>:4747 no celular (no macOS: ipconfig getifaddr en0; se vier vazio, ipconfig getifaddr en1). No modo Node, use HABBLAUD_HOST=0.0.0.0 npm start.

⚠️ Com isso, qualquer aparelho da rede vê a atividade dos agentes (comandos, arquivos, títulos das sessões). Use só em redes de confiança. O terminal (e com ele responder e mandar mensagens pelo escritório) fica desligado enquanto a porta estiver exposta. Para voltar: apague o .env e rode npm run docker:up -- --no-build.

Atualizar

A versão em uso aparece na barra superior, ao lado de "Conectado", e em Configurações › Sobre. A cada 6 horas o Habblaud confere no GitHub se saiu uma versão nova (as releases deste repositório). Quando sai, aparece o selo verde Nova versão no lugar do número, com um aviso e um ponto no botão de configurações. Em Sobre ficam o link do que mudou e o botão Verificar agora (as notas de cada versão também estão no CHANGELOG.md). Para atualizar:

git pull
npm install
npm run docker:up        # ou: npm run build && npm start (e depois npm run mod:install)

Se o git pull parar com "Your local changes to the following files would be overwritten by merge: package-lock.json", rode git checkout -- package-lock.json e repita: até a 0.4.0, o npm install alterava esse arquivo.

O docker:up também atualiza o mod nas contas em que ele já está instalado (nunca instala sozinho) e avisa: "Mod atualizado para 0.3.0 na Conta D; sessões abertas: /reload-plugins". No modo Node, rode npm run mod:install depois de atualizar. Para não consultar o GitHub, use HABBLAUD_UPDATE_CHECK=0 (no .env, para o Docker).

Vindo do CodeTown

O Habblaud se chamava CodeTown até a 0.3.2. Depois do git pull, rode uma vez:

npm install
npm run mod:install      # troca o marketplace e os plugins codetown pelos habblaud, em cada conta
npm run docker:up        # tira o container codetown e copia os dados do volume antigo para o novo
  • Mod: até o mod:install, o Claude Code das contas reclama do marketplace codetown (as pastas mod/codetown* mudaram de nome). O docker:up avisa, mas não troca sozinho.
  • Docker: o volume antigo (codetown_codetown-data) fica intacto. Depois de conferir que os nomes dos personagens vieram, apague-o com docker volume rm codetown_codetown-data, e a imagem antiga com docker image rm codetown:local.
  • Pasta local: ~/.codetown vira ~/.habblaud sozinha (no servidor, no docker:up e no mod:install).
  • Navegador: preferências e moedinhas passam para as chaves novas na primeira vez que a página abre.
  • Variáveis: as CODETOWN_* não valem mais; renomeie para HABBLAUD_* no .env (ex.: HABBLAUD_BIND). O servidor e o docker:up avisam quando acham uma antiga.
  • Repositório: agora é github.com/marmottajr/habblaud (o GitHub redireciona o endereço antigo). Para acertar o clone: git remote set-url origin https://github.com/marmottajr/habblaud.git. A pasta local pode continuar com o nome antigo.
  • Backups do settings.json feitos antes da troca continuam com o nome settings.json.codetown-backup-<data>.

Desinstalar

npm run mod:uninstall                       # tira o mod, os plugins de permissões e de mensagens e o marketplace
npm run codex:uninstall                     # tira os hooks do Habblaud do Codex (os outros ficam)
npm run docker:down                         # para o container
docker volume rm habblaud_habblaud-data     # apaga os dados do container (nomes, linha do tempo e estatísticas)
docker image rm habblaud:local              # apaga a imagem
rm -rf ~/.habblaud                          # apaga os dados locais (uso capturado, nomes, linha do tempo e estatísticas)

Depois é só apagar a pasta do projeto — rode o mod:uninstall antes, senão o Claude Code das contas passa a reclamar do marketplace que sumiu. Se você usou o jeito antigo, rode também npm run usage:uninstall (devolve o statusline original) e npm run hooks:uninstall (tira o hook de permissão); sem eles, o statusline das contas passa a dar erro (e o hook, a falhar em silêncio). O mod:install, que tira o tap e o hook antigos, e os instaladores e desinstaladores antigos deixam cópias settings.json.habblaud-backup-<data> na pasta de cada conta (ex.: ~/.claude/); apague-as se não precisar mais.

Como usar

O que cada personagem está fazendo

EstadoO que você vê
TrabalhandoNa mesa, digitando. O monitor e um balão mostram a atividade: 📖 lendo, ✏️ editando, 💻 terminal, 🧪 testes, 🌐 pesquisando…
Precisa de vocêCorre para a mesa e levanta a mão, com alerta piscando: está esperando uma permissão ou resposta no terminal.
Esperando o shellTerminou o turno mas deixou um comando rodando (testes, build, deploy…), então fica na mesa com uma ampulheta virando sobre a cabeça e o terminal em progresso: come pipoca assistindo, depois de 3 min cruza os braços e gira na cadeira, depois de 10 min junta teia de aranha e depois de 25 min cochila; quando o comando termina, levanta e comemora com confete (ou ganha uma nuvem de chuva, se falhou). Depois de 40 s, se houver colegas à toa, pode sair para uma roda — com a ampulheta na cabeça.
OciosoTerminou o turno e passeia: café na copa, bebedouro, banheiro, sofá do lounge, celular no puff, espelho. Com colegas à toa, entra numa roda: TV, videogame, ping-pong, papo na copa, jokenpô (veja Vida social). Depois de 10 min parado, cochila — mas um colega pode acordá-lo para uma roda.
Subagente concluídoVai até o agente que o chamou, entrega o resultado e sai pelo elevador.
Sessão encerradaVai embora; se era o último da sala, apaga a luz antes de sair.

Cada personagem tem um nome brasileiro único (Marina, Henrique, Luan…) que se mantém enquanto a sessão existir, e um chip colorido com a letra da conta (C, D…).

Interface

  • Topo: contadores (salas, agentes, trabalhando, subagentes, shells rodando, precisam de você) e um cartão de uso por conta. O de shells só aparece enquanto algum comando está rodando; clique nele para ir até quem espera. Embaixo do logo ficam a conexão e a versão em uso, que vira o selo Nova versão quando sai uma versão nova (veja Atualizar).
  • Painel lateral: busca, filtro por conta e a lista de salas com seus agentes e subagentes.
  • Gaveta de detalhes: clique num personagem (no prédio ou na lista) para ver atividade, tarefas, subagentes, linha do tempo e estatísticas (ferramentas, tokens, custo, linhas alteradas, modelo, branch).
  • Feed: as últimas atividades de todo o escritório.
  • Configurações (⚙): nomes, balões, quanto os ociosos passeiam, ciclo dia/noite, sons, notificações do navegador, modo demonstração e Sobre (versão em uso e versão nova). Ajuda (?): legenda completa e atalhos.
  • Meu dia (📊): para onde foi o tempo do dia (veja Meu dia). Timelapse e Histórico (os relógios da barra superior): veja Timelapse do dia e Terminal.

Câmera: arraste para mover, role para dar zoom, clique duplo num personagem para segui-lo.

Atalhos: / busca · F seguir o selecionado · T terminal · L timelapse · M meu dia · P próximo pedido (permissão ou pergunta) · O ou 0 visão geral · Esc limpar seleção · [ painel lateral · ] feed · setas/WASD mover · + - zoom · ? ajuda.

Dia, noite e sons

O escritório acompanha a hora local: de madrugada e à noite o gramado e a rua ficam azulados e escuros, os postes, os abajures, as máquinas e os monitores ligados acendem halos de luz, as salas com gente ficam iluminadas (a luz que apaga quando a sala esvazia continua valendo), os carros passam de farol aceso e aparecem vaga-lumes no jardim. No amanhecer (~5–7 h) e no entardecer (~17–19 h) tudo ganha um tom quente, e durante o dia o sol entra pelas janelas e desenha faixas de luz no piso — curtas ao meio-dia, longas e alaranjadas no fim da tarde. Em Configurações › Ciclo dia/noite dá para escolher automático, sempre dia ou sempre noite; para testar um horário, use ?hora=21:30 na URL.

Os sons vêm desligados. Ligados em Configurações › Sons, são sintetizados no próprio navegador (sem arquivos de áudio) e baixinhos: o teclado de quem trabalha nas salas à vista, o "ding" do elevador quando alguém chega ou vai embora, o sino quando alguém precisa de você, o estalo de tarefa concluída e o pingue-pongue e o fliperama das rodas. Há volume geral e cada categoria liga e desliga à parte. Com a aba oculta, só o sino toca.

Terminal

Clique num agente e use Abrir terminal para ver a conversa da sessão como o Claude Code mostra: os prompts, as respostas, cada ferramenta chamada (com o comando ou o diff) e o resultado, atualizados ao vivo. Vale para agentes principais e subagentes; no modo demonstração, a conversa é fictícia.

No rodapé, para agentes principais, dá para digitar: Enter manda e Shift+Enter quebra a linha. O texto entra na sessão como se você tivesse digitado no terminal dele (veja Mandar mensagens); se o agente estiver ocupado, entra quando ele terminar o que está fazendo. Subagentes e sessões do histórico continuam só para ler.

  • Busca: com o terminal em foco, Ctrl+F (⌘F no Mac) ou a lupa do cabeçalho abre a busca na conversa, sem diferenciar maiúsculas nem acentos. O contador mostra a posição ("3/17"); Enter e Shift+Enter vão para o próximo e o anterior, abrindo os blocos recolhidos ("… +N linhas") onde o termo estiver. A busca continua valendo enquanto chegam mensagens novas. Esc fecha a busca; o seguinte fecha o terminal.
  • Filtro: Tudo, Só prompts (os seus prompts e as respostas finais do agente, sem os passos intermediários) ou Sem ferramentas.
  • Copiar: passe o mouse (ou o foco) sobre um prompt, uma resposta, um comando ou um resultado para copiá-lo.
  • Histórico: o relógio da barra superior lista as sessões dos últimos 7 dias de todas as contas (até 150), agrupadas por dia, com busca por título, projeto ou conta. Uma sessão encerrada abre no terminal com projeto, título e data no cabeçalho e "Sessão encerrada às …" no rodapé; uma sessão ainda aberta abre o terminal ao vivo do agente.

Como o terminal (e o histórico) mostra a conversa inteira, ele só existe quando o Habblaud está acessível apenas pelo próprio computador (o padrão) e só abre por http://localhost ou http://127.0.0.1. Com a porta liberada para a rede (HABBLAUD_BIND=0.0.0.0 ou HABBLAUD_HOST=0.0.0.0), ele fica desligado. Detalhes em Privacidade e segurança.

Timelapse do dia

O botão Timelapse (relógio com a seta de voltar, ou a tecla L) reproduz o dia em alta velocidade: salas acendendo e apagando, agentes chegando, trabalhando, esperando você, indo para as rodas, subagentes entrando e saindo. A barra de reprodução tem o dia, play/pausa, a velocidade (60×, 180× ou 600×: um dia de 10 h em 10, 3⅓ ou 1 min), a linha do tempo arrastável com o gráfico de quem estava presente e trabalhando, as marcas dos picos (clique para pular até lá) e Voltar ao vivo. Enquanto isso, o escritório fica levemente sépia, com o selo REPLAY 14:32, e o feed continua mostrando o que acontece agora.

O servidor grava a linha do tempo a partir do momento em que está ligado (não dá para reconstruir o passado): res

Source 1 files
hooks/register.ts 300 lines
1// Mensagens pelo escritório (Claude Code 2.1.287+): leva para a sessão o que você digita no Habblaud (no painel
2// do agente ou no terminal da tecla T), como se você tivesse digitado no terminal.
3//
4// O que faz, e `claude plugin validate` lista exatamente o que o módulo chama (a saída está no mod/README.md):
5//
6// 1. A cada 2 s, só onde a sessão desenha (`$.session.surfaces()` vazia = `claude -p`/SDK: nada a fazer), pergunta
7//    ao Habblaud local se há mensagens para ela: POST http://127.0.0.1:<HABBLAUD_PORT ou 4747>/api/mod/inbox com
8//    {session, account}. A pergunta também marca a presença da sessão: é ela que acende, no escritório, a caixa de
9//    mensagem deste agente. Fora do ar, ou respondendo qualquer coisa que não seja 2xx (403 = mensagens desligadas
10//    no Habblaud; 404 = uma versão sem a rota), passa a perguntar a cada 30 s até ele voltar.
11// 2. Cada mensagem, na ordem, vai para a sessão por `$.prompt.submit({ text, asUser: true })`: o modelo a lê como
12//    sua, sem o "The habblaud-mensagens plugin sent a message" (o registro da sessão ainda nomeia o plugin). O
13//    texto vai exatamente como chegou. `{drop}` (um hook recusou) ou exceção = falha, com o motivo.
14//    Com a sessão no meio de um turno, o Claude Code guarda o prompt até ela ficar livre e a chamada pode demorar a
15//    voltar: a espera por mensagem é de no máximo 2 s. Passou disso, a mensagem está na fila da sessão e conta
16//    como entregue, e as seguintes da mesma rodada vão em seguida, na ordem, sem esperar. Assim a rodada nunca
17//    passa de uns poucos segundos: a presença (10 s no servidor) e a confirmação (30 s) não vencem por uma
18//    sessão ocupada. O preço: um hook que recuse a mensagem depois desses 2 s não chega ao escritório.
19// 3. Um único POST /api/mod/inbox/ack com {session, results: [{id, ok, error?}]}. Sem mensagens, sem ack.
20//
21// Por que um plugin à parte do mod `habblaud`: aquele só observa (uso do plano, aviso embaixo do prompt,
22// /habblaud) e nunca muda o que a sessão faz. Este age: digita na sessão em seu nome. Separado, dá para ficar sem
23// ele (npm run mod:install -- --sem-mensagens) e a análise estática de cada um continua curta.
24//
25// Regras: nenhum hook lança (um hook que lança é pulado, mas o que ele deixou pela metade fica): cada passo tem seu
26// try/catch e falha = silêncio. Uma rodada por vez (um Habblaud lento não acumula perguntas). Nada de $.process,
27// $.model, $.fs, leitura da conversa nem decisão de ferramenta; a rede é só 127.0.0.1. O estado fica em variáveis
28// do módulo: um hot reload recomeça do zero (uma mensagem buscada e não confirmada falha no servidor em 30 s).
29// O Claude Code pode carregar o plugin de uma cópia só dele: os ajudantes de caminho são cópias dos de
30// mod/habblaud/hooks/register.ts, não importações.
31//
32// Análise estática do Claude Code: cada chamada é escrita por extenso ($.noun.método), o nome do evento é
33// sempre uma string literal e `$` só é passado para funções declaradas no topo deste arquivo.
34import type { EngineInterface, HttpResponse, Register } from 'claude-code'
35
36/** Porta padrão do Habblaud (a mesma do servidor, do mod e do docker-compose). */
37export const DEFAULT_PORT = 4747
38/** Intervalo das perguntas ao Habblaud; fora do ar, recua para o segundo até ele voltar. */
39export const POLL_MS = 2_000
40export const OFFLINE_POLL_MS = 30_000
41/** Quanto esperar o Habblaud local responder (`$.http.fetch` não aceita AbortSignal: a corrida é com um timer). */
42export const FETCH_TIMEOUT_MS = 2_000
43/** Quanto esperar `$.prompt.submit` voltar antes de dar a mensagem como na fila da sessão. */
44export const SUBMIT_WAIT_MS = 2_000
45/** Tamanho máximo do motivo de uma falha (vem de um hook ou de uma exceção; o texto da mensagem nunca é cortado). */
46export const MAX_REASON = 500
47
48/** Mensagem como POST /api/mod/inbox entrega (shared/types.ts, InboxMessage). */
49export interface InboxMessage {
50  id: string
51  /** O texto como foi digitado; vazio quando o servidor mandou algo que não é texto. */
52  text: string
53}
54
55/** Resultado de uma mensagem, como POST /api/mod/inbox/ack recebe. */
56export interface AckResult {
57  id: string
58  ok: boolean
59  error?: string
60}
61
62/** Desfecho de um `$.prompt.submit`. */
63type Submitted = { ok: true } | { ok: false; error: string }
64
65interface ModEnv {
66  /** Id da conta (o nome da pasta, como o servidor chama); ausente sem HOME (ou USERPROFILE) nem CLAUDE_CONFIG_DIR. */
67  accountId?: string
68  port: number
69}
70
71// ---------------------------------------------------------------------------------------------
72// Funções puras (testadas em tests/habblaud-mensagens.test.ts)
73// ---------------------------------------------------------------------------------------------
74
75/**
76 * Normaliza um caminho (barras repetidas, `.`, `..` e a barra do fim): o ambiente do mod não tem node:path. Cópia
77 * de mod/habblaud: caminhos do Windows (com drive ou UNC) saem com `/` e o drive conta como raiz; nos outros, `\`
78 * é parte do nome.
79 */
80export function normalizePath(p: string): string {
81  const win = /^(?:[A-Za-z]:|\\\\)/.test(p)
82  const slashed = win ? p.replace(/\\/g, '/') : p
83  const drive = /^[A-Za-z]:(?=\/|$)/.exec(slashed)?.[0] ?? (win && slashed.startsWith('//') ? '/' : '')
84  const rest = slashed.slice(drive.length)
85  const abs = rest.startsWith('/')
86  const out: string[] = []
87  for (const seg of rest.split('/')) {
88    if (!seg || seg === '.') continue
89    if (seg === '..') {
90      if (out.length && out[out.length - 1] !== '..') out.pop()
91      else if (!abs) out.push(seg)
92      continue
93    }
94    out.push(seg)
95  }
96  const joined = out.join('/')
97  return drive + (abs ? `/${joined}` : joined || (drive ? '' : '.'))
98}
99
100/** `~` no começo vira o HOME. */
101function expandHome(p: string, home: string | undefined): string {
102  return home && /^~(?=[\\/]|$)/.test(p) ? home + p.slice(1) : p
103}
104
105/** Config dir da conta: o primeiro item de CLAUDE_CONFIG_DIR (com `~` expandido) ou ~/.claude. */
106export function configDirOf(claudeConfigDir: string | undefined, home: string | undefined): string | undefined {
107  const first = (claudeConfigDir ?? '').split(',')[0]?.trim() ?? ''
108  if (first) return normalizePath(expandHome(first, home))
109  return home ? normalizePath(`${home}/.claude`) : undefined
110}
111
112/** Id da conta = nome da pasta (AccountInfo.id do servidor, ex.: ".claude-conta2"). */
113export function accountIdOf(configDir: string): string | undefined {
114  const name = configDir.split('/').filter(Boolean).pop()
115  return name && name !== '.' && name !== '..' ? name : undefined
116}
117
118export function portOf(raw: string | undefined): number {
119  const n = Number.parseInt(raw ?? '', 10)
120  return Number.isInteger(n) && n > 0 && n < 65_536 ? n : DEFAULT_PORT
121}
122
123/** Corpo de POST /api/mod/inbox: a conta só quando se sabe qual é. */
124export function inboxBody(session: string, account: string | undefined): string {
125  return JSON.stringify(account ? { session, account } : { session })
126}
127
128/** Corpo de POST /api/mod/inbox/ack. */
129export function ackBody(session: string, results: readonly AckResult[]): string {
130  return JSON.stringify({ session, results })
131}
132
133/**
134 * Lê a resposta de POST /api/mod/inbox; fora do formato = undefined (vale "fora do ar"). Item sem id fica de fora
135 * (não há como confirmá-lo); com id e sem texto, entra com texto vazio, para ser confirmado como falha em vez de o
136 * servidor esperar 30 s.
137 */
138export function parseInbox(text: string): InboxMessage[] | undefined {
139  let j: unknown
140  try {
141    j = JSON.parse(text)
142  } catch {
143    return undefined
144  }
145  if (!j || typeof j !== 'object' || Array.isArray(j)) return undefined
146  const list = (j as Record<string, unknown>).messages
147  if (!Array.isArray(list)) return undefined
148  const out: InboxMessage[] = []
149  for (const item of list) {
150    if (!item || typeof item !== 'object') continue
151    const m = item as Record<string, unknown>
152    if (typeof m.id !== 'string' || !m.id) continue
153    out.push({ id: m.id, text: typeof m.text === 'string' ? m.text : '' })
154  }
155  return out
156}
157
158/** O motivo de uma falha numa linha só, com tamanho limitado: o texto de um `{drop}` ou a mensagem de uma exceção. */
159export function reasonText(raw: unknown, fallback: string): string {
160  const msg = typeof raw === 'string' ? raw : raw && typeof (raw as { message?: unknown }).message === 'string' ? (raw as { message: string }).message : ''
161  const line = msg.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
162  if (!line) return fallback
163  return line.length > MAX_REASON ? `${line.slice(0, MAX_REASON - 1)}…` : line
164}
165
166// ---------------------------------------------------------------------------------------------
167// Estado do módulo (some num hot reload) e o que fala com o Claude Code
168// ---------------------------------------------------------------------------------------------
169
170let env: ModEnv | undefined
171let timer: { cancel: () => void } | undefined
172let timerMs = 0
173/** Uma rodada por vez: um Habblaud lento ou uma sessão ocupada não empilham rodadas. */
174let polling = false
175
176/** O ambiente da sessão, lido uma vez por carga (cada nome escrito por extenso, como a análise exige). */
177async function readEnv($: EngineInterface): Promise<ModEnv> {
178  if (env) return env
179  // No Windows, HOME só existe se alguém o definir: vale o USERPROFILE (a mesma ordem do mod habblaud).
180  const home = (await $.env.get('HOME'))?.trim() || (await $.env.get('USERPROFILE'))?.trim() || undefined
181  const configDir = configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), home)
182  const next: ModEnv = { port: portOf(await $.env.get('HABBLAUD_PORT')) }
183  const accountId = configDir ? accountIdOf(configDir) : undefined
184  if (accountId) next.accountId = accountId
185  env = next
186  return next
187}
188
189/** O que `promise` resolver em até `ms`; passou disso, undefined (a promessa segue sozinha). */
190async function waitAtMost<T>($: EngineInterface, promise: Promise<T>, ms: number): Promise<T | undefined> {
191  let wait: { cancel: () => void } | undefined
192  const timeout = new Promise<undefined>((resolve) => {
193    wait = $.clock.after(ms, () => resolve(undefined))
194  })
195  try {
196    return await Promise.race([promise, timeout])
197  } finally {
198    wait?.cancel()
199  }
200}
201
202/** POST de um JSON ao Habblaud local com prazo de 2 s: a resposta, ou undefined (fora do ar, travado). Nunca lança. */
203async function postJson($: EngineInterface, port: number, path: string, body: string): Promise<HttpResponse | undefined> {
204  try {
205    const request = $.http.fetch(`http://127.0.0.1:${port}${path}`, {
206      method: 'POST',
207      // Sem o Content-Type, o guarda do servidor recusa o POST (415).
208      headers: { 'content-type': 'application/json', accept: 'application/json' },
209      body,
210    })
211    return await waitAtMost($, request, FETCH_TIMEOUT_MS)
212  } catch {
213    return undefined
214  }
215}
216
217/** Uma mensagem para a sessão, como se você a tivesse digitado. Nunca lança. */
218async function submitOne($: EngineInterface, text: string): Promise<Submitted> {
219  try {
220    const r = await $.prompt.submit({ text, asUser: true })
221    if (typeof r?.drop === 'string') return { ok: false, error: reasonText(r.drop, 'um hook da sessão recusou a mensagem') }
222    return { ok: true }
223  } catch (err) {
224    return { ok: false, error: reasonText(err, 'a sessão não aceitou a mensagem') }
225  }
226}
227
228/** Manda as mensagens à sessão, na ordem, e devolve o resultado de cada uma. Nunca lança. */
229async function deliver($: EngineInterface, messages: readonly InboxMessage[]): Promise<AckResult[]> {
230  const results: AckResult[] = []
231  /** Uma chamada passou do prazo: a sessão está ocupada e as seguintes entram na fila atrás dela, sem esperar. */
232  let queued = false
233  for (const m of messages) {
234    if (!m.text.trim()) {
235      results.push({ id: m.id, ok: false, error: 'mensagem vazia' })
236      continue
237    }
238    const submitted = submitOne($, m.text)
239    if (queued) {
240      void submitted
241      results.push({ id: m.id, ok: true })
242      continue
243    }
244    const out = await waitAtMost($, submitted, SUBMIT_WAIT_MS)
245    if (!out) {
246      queued = true
247      results.push({ id: m.id, ok: true })
248    } else results.push(out.ok ? { id: m.id, ok: true } : { id: m.id, ok: false, error: out.error })
249  }
250  return results
251}
252
253/** (Re)agenda as rodadas: 2 s com o Habblaud no ar, 30 s fora do ar. */
254function schedule($: EngineInterface, ms: number): void {
255  if (timer && timerMs === ms) return
256  timer?.cancel()
257  timerMs = ms
258  timer = $.clock.every(ms, () => {
259    void round($)
260  })
261}
262
263/** Uma rodada: busca as mensagens desta sessão, manda cada uma e confirma. Nunca lança. */
264async function round($: EngineInterface): Promise<void> {
265  if (polling) return
266  polling = true
267  try {
268    const surfaces = await $.session.surfaces()
269    if (!surfaces.length) return
270    const e = await readEnv($)
271    const session = await $.session.id()
272    const res = await postJson($, e.port, '/api/mod/inbox', inboxBody(session, e.accountId))
273    const messages = res?.ok ? parseInbox(res.text) : undefined
274    if (!messages) {
275      schedule($, OFFLINE_POLL_MS)
276      return
277    }
278    schedule($, POLL_MS)
279    if (!messages.length) return
280    const results = await deliver($, messages)
281    // Falhou a confirmação: o servidor dá as mensagens como falhas em 30 s ("a sessão não confirmou a entrega").
282    await postJson($, e.port, '/api/mod/inbox/ack', ackBody(session, results))
283  } catch {
284    // silêncio: a próxima rodada tenta de novo
285  } finally {
286    polling = false
287  }
288}
289
290export const register: Register = (on) => {
291  on('session.start', async ($, e, next) => {
292    try {
293      schedule($, POLL_MS)
294    } catch {
295      // sem as rodadas, a sessão segue como sempre
296    }
297    return next(e)
298  })
299}
300