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.

<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.
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.

<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 apagada | 2. Alguém acende a luz e todos trabalham | 3. O último sai e apaga a luz | 4. A sala vira 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).
git clone https://github.com/marmottajr/habblaud.git
cd habblaud
npm install
O
npm installsó 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.
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. 🎉
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:
/habblaud passa a existir no Claude Code;habblaud-permissoes (veja Responder pelo escritório);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:installde 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.
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).
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
.enve rodenpm run docker:up -- --no-build.
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 pullparar com "Your local changes to the following files would be overwritten by merge: package-lock.json", rodegit checkout -- package-lock.jsone repita: até a 0.4.0, onpm installalterava 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).
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: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.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.~/.codetown vira ~/.habblaud sozinha (no servidor, no docker:up e no mod:install).CODETOWN_* não valem mais; renomeie para HABBLAUD_* no .env (ex.: HABBLAUD_BIND). O servidor e o docker:up avisam quando acham uma antiga.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.settings.json feitos antes da troca continuam com o nome settings.json.codetown-backup-<data>.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.
| Estado | O que você vê |
|---|---|
| Trabalhando | Na 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 shell | Terminou 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. |
| Ocioso | Terminou 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ído | Vai até o agente que o chamou, entrega o resultado e sai pelo elevador. |
| Sessão encerrada | Vai 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…).
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.
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.
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.
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.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.
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
hooks/register.ts 300 lines1// 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