Liga a sessão ao Habblaud: grava o uso do plano (5h e semanal) para o escritório, avisa embaixo do prompt quando outra sessão precisa de você e adiciona o…

<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 411 lines1// Mod do Habblaud (Claude Code 2.1.287+): liga cada sessão ao escritório sem ler a conversa.
2//
3// Faz três coisas pequenas, e `claude plugin validate` lista exatamente o que o módulo chama (a saída
4// está no mod/README.md):
5//
6// 1. Uso do plano (substitui o tap de statusline): em `session.start` e a cada `session.measure`
7// (depois de cada turno e quando um limite anda um ponto inteiro) grava
8// <HABBLAUD_USAGE_DIR ou ~/.habblaud/usage>/<conta>.json no MESMO formato do scripts/statusline-tap.mjs
9// ({accountId, configDir, fetchedAt, five_hour, seven_day}, `resets_at` em segundos) mais
10// `source: "mod"`. O servidor lê esses arquivos em server/accounts/statusline.ts.
11// 2. Uma linha embaixo do prompt quando OUTRA sessão precisa de você: a cada 5 s pergunta ao Habblaud
12// local (GET /api/mod/summary, que já tira da lista esta sessão e os subagentes dela). Fora do ar, a
13// linha some e as perguntas passam a ser a cada 30 s até ele voltar. Só onde a sessão desenha
14// (`$.session.surfaces()` vazia = `claude -p`/SDK: nada a mostrar, nada a perguntar).
15// 3. /habblaud: resumo do escritório, respondido pelo próprio mod (não chama o modelo, não gasta uso).
16//
17// Regras: nenhum hook lança (um hook que lança é pulado, mas o que ele deixou pela metade fica): cada
18// passo tem seu try/catch e falha = silêncio. Nada de $.process, $.model, $.prompt, decisão de
19// ferramenta nem leitura da conversa; a rede é só 127.0.0.1. O estado fica em variáveis do módulo: um
20// hot reload recomeça do zero, o que aqui custa no máximo uma regravação a mais do arquivo de uso.
21//
22// Análise estática do Claude Code: cada chamada é escrita por extenso ($.noun.método), o nome do evento é
23// sempre uma string literal e `$` só é passado para funções declaradas no topo deste arquivo.
24import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
25
26/** Porta padrão do Habblaud (a mesma do servidor, do hook de permissão e do docker-compose). */
27export const DEFAULT_PORT = 4747
28/** Intervalo das perguntas ao Habblaud; fora do ar, recua para o segundo até ele voltar. */
29export const POLL_MS = 5_000
30export const OFFLINE_POLL_MS = 30_000
31/** Quanto esperar o Habblaud local responder (`$.http.fetch` não aceita AbortSignal: a corrida é com um timer). */
32export const FETCH_TIMEOUT_MS = 2_000
33/** Valores idênticos gravados há menos que isto não são regravados (a mesma regra do tap). */
34export const MIN_REWRITE_MS = 10_000
35/** Nomes na linha embaixo do prompt antes do "e mais N". */
36const MAX_NAMES = 3
37
38/** Uma janela no formato do tap: percentual 0–100 e reinício em SEGUNDOS desde a época. */
39export interface UsageWindow {
40 utilization: number
41 resets_at?: number
42}
43
44/** O arquivo de uso: o formato do tap mais `source`, para saber quem gravou. */
45export interface UsageRecord {
46 accountId: string
47 configDir: string
48 fetchedAt: number
49 five_hour?: UsageWindow
50 seven_day?: UsageWindow
51 source: 'mod'
52}
53
54/** Quem precisa de você, como GET /api/mod/summary devolve (sem demo e sem esta sessão). */
55export interface WaitingAgent {
56 id: string
57 name: string
58 room: string
59 account: string
60 waitingFor: string
61 since?: number
62 /** Há pedido de permissão para responder pelo escritório. */
63 answerable: boolean
64}
65
66export interface Summary {
67 version: string
68 agents: number
69 working: number
70 waiting: WaitingAgent[]
71}
72
73/** Resultado de uma pergunta ao Habblaud: resposta, fora do ar ou uma versão antiga, sem a rota do mod. */
74type Asked = { kind: 'ok'; summary: Summary } | { kind: 'offline' } | { kind: 'outdated' }
75
76interface ModEnv {
77 /** Config dir da conta e o id dela (basename), como o tap calcula; sem HOME (ou USERPROFILE) nem CLAUDE_CONFIG_DIR, ausentes. */
78 configDir?: string
79 accountId?: string
80 /** Pasta do uso; ausente sem HOME (ou USERPROFILE) nem HABBLAUD_USAGE_DIR. */
81 usageDir?: string
82 port: number
83}
84
85// ---------------------------------------------------------------------------------------------
86// Funções puras (testadas em tests/habblaud.test.ts)
87// ---------------------------------------------------------------------------------------------
88
89/**
90 * Normaliza um caminho (barras repetidas, `.`, `..` e a barra do fim): o ambiente do mod não tem node:path.
91 * Caminhos do Windows (com drive ou UNC) saem com `/`, que o Windows também aceita, e a letra do drive (`C:`) conta
92 * como raiz: assim o servidor no Docker, que é Linux, ainda acha o nome da pasta da conta no configDir gravado. Nos
93 * outros, `\` é parte do nome (Linux e macOS) e fica como está.
94 */
95export function normalizePath(p: string): string {
96 const win = /^(?:[A-Za-z]:|\\\\)/.test(p)
97 const slashed = win ? p.replace(/\\/g, '/') : p
98 // Raiz: o drive, ou uma das duas barras do UNC (`\\nas\share` vira `//nas/share`, que precisa das duas).
99 const drive = /^[A-Za-z]:(?=\/|$)/.exec(slashed)?.[0] ?? (win && slashed.startsWith('//') ? '/' : '')
100 const rest = slashed.slice(drive.length)
101 const abs = rest.startsWith('/')
102 const out: string[] = []
103 for (const seg of rest.split('/')) {
104 if (!seg || seg === '.') continue
105 if (seg === '..') {
106 if (out.length && out[out.length - 1] !== '..') out.pop()
107 else if (!abs) out.push(seg)
108 continue
109 }
110 out.push(seg)
111 }
112 const joined = out.join('/')
113 return drive + (abs ? `/${joined}` : joined || (drive ? '' : '.'))
114}
115
116/** `~` no começo vira o HOME (como o tap faz com CLAUDE_CONFIG_DIR e HABBLAUD_USAGE_DIR). */
117function expandHome(p: string, home: string | undefined): string {
118 return home && /^~(?=[\\/]|$)/.test(p) ? home + p.slice(1) : p
119}
120
121/** Config dir da conta: o primeiro item de CLAUDE_CONFIG_DIR (com `~` expandido) ou ~/.claude. */
122export function configDirOf(claudeConfigDir: string | undefined, home: string | undefined): string | undefined {
123 const first = (claudeConfigDir ?? '').split(',')[0]?.trim() ?? ''
124 if (first) return normalizePath(expandHome(first, home))
125 return home ? normalizePath(`${home}/.claude`) : undefined
126}
127
128/** Id da conta = nome da pasta (AccountInfo.id do servidor, ex.: ".claude-conta2"). */
129export function accountIdOf(configDir: string): string | undefined {
130 const name = configDir.split('/').filter(Boolean).pop()
131 return name && name !== '.' && name !== '..' ? name : undefined
132}
133
134export function usageDirOf(habblaudUsageDir: string | undefined, home: string | undefined): string | undefined {
135 const d = (habblaudUsageDir ?? '').trim()
136 if (d) return normalizePath(expandHome(d, home))
137 return home ? normalizePath(`${home}/.habblaud/usage`) : undefined
138}
139
140export function portOf(raw: string | undefined): number {
141 const n = Number.parseInt(raw ?? '', 10)
142 return Number.isInteger(n) && n > 0 && n < 65_536 ? n : DEFAULT_PORT
143}
144
145/**
146 * Uma janela de `$.session.usage().rateLimits` no formato do tap. `percentUsed` vem de 0 a 100 (passa de
147 * 100 só num spend limit estourado, que não gravamos); `resetsAt` vem em ISO 8601 e o tap grava SEGUNDOS.
148 */
149export function windowOf(rl: SessionRateLimit | undefined): UsageWindow | undefined {
150 if (!rl || typeof rl.percentUsed !== 'number' || !Number.isFinite(rl.percentUsed)) return undefined
151 const w: UsageWindow = { utilization: Math.min(100, Math.max(0, rl.percentUsed)) }
152 const ms = typeof rl.resetsAt === 'string' ? Date.parse(rl.resetsAt) : Number.NaN
153 if (Number.isFinite(ms)) w.resets_at = Math.round(ms / 1_000)
154 return w
155}
156
157/**
158 * O registro gravado, ou undefined sem janela utilizável (fora de uma assinatura, ou antes da primeira
159 * resposta da API, `rateLimits` vem vazia). Só `five_hour` e `seven_day`: o `spend_limit` de um gateway
160 * não é limite do plano.
161 */
162export function usageRecord(rateLimits: readonly SessionRateLimit[], configDir: string, now: number): UsageRecord | undefined {
163 const accountId = accountIdOf(configDir)
164 if (!accountId) return undefined
165 const five = windowOf(rateLimits.find((r) => r.kind === 'five_hour'))
166 const week = windowOf(rateLimits.find((r) => r.kind === 'seven_day'))
167 if (!five && !week) return undefined
168 const rec: UsageRecord = { accountId, configDir, fetchedAt: now, source: 'mod' }
169 if (five) rec.five_hour = five
170 if (week) rec.seven_day = week
171 return rec
172}
173
174/** Texto numa linha só (nomes e salas vêm do servidor; uma quebra de linha estragaria a linha de status). */
175function oneLine(s: string): string {
176 return s.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim()
177}
178
179function str(v: unknown): string | undefined {
180 return typeof v === 'string' && v.trim() ? oneLine(v) : undefined
181}
182
183function count(v: unknown): number {
184 return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0
185}
186
187/** Lê a resposta de GET /api/mod/summary; qualquer coisa fora do formato = undefined (vale "fora do ar"). */
188export function parseSummary(text: string): Summary | undefined {
189 let j: unknown
190 try {
191 j = JSON.parse(text)
192 } catch {
193 return undefined
194 }
195 if (!j || typeof j !== 'object' || Array.isArray(j)) return undefined
196 const r = j as Record<string, unknown>
197 if (!Array.isArray(r.waiting)) return undefined
198 const waiting: WaitingAgent[] = []
199 for (const item of r.waiting) {
200 if (!item || typeof item !== 'object') continue
201 const w = item as Record<string, unknown>
202 const name = str(w.name)
203 if (!name) continue
204 const agent: WaitingAgent = {
205 id: str(w.id) ?? name,
206 name,
207 room: str(w.room) ?? '?',
208 account: str(w.account) ?? '',
209 waitingFor: str(w.waitingFor) ?? 'responder no terminal',
210 answerable: w.answerable === true,
211 }
212 if (typeof w.since === 'number' && Number.isFinite(w.since)) agent.since = w.since
213 waiting.push(agent)
214 }
215 return { version: str(r.version) ?? '?', agents: count(r.agents), working: count(r.working), waiting }
216}
217
218/** A linha embaixo do prompt: undefined quando ninguém (além desta sessão) precisa de você. */
219export function statusText(waiting: readonly WaitingAgent[]): string | undefined {
220 const first = waiting[0]
221 if (!first) return undefined
222 if (waiting.length === 1) return `🏢 ${first.name} precisa de você em ${first.room}`
223 const shown = waiting.slice(0, MAX_NAMES).map((w) => `${w.name} (${w.room})`)
224 const rest = waiting.length - shown.length
225 return `🏢 ${waiting.length} precisam de você: ${shown.join(', ')}${rest > 0 ? ` e mais ${rest}` : ''}`
226}
227
228/** A resposta do /habblaud com o Habblaud no ar. */
229export function summaryText(s: Summary, url: string): string {
230 const agents = s.agents === 1 ? '1 agente' : `${s.agents} agentes`
231 const waiting = s.waiting.length === 0 ? 'ninguém precisa de você' : s.waiting.length === 1 ? '1 precisa de você' : `${s.waiting.length} precisam de você`
232 const lines = [`Habblaud ${s.version} em ${url}`, `${agents} · ${s.working} trabalhando · ${waiting}`]
233 for (const w of s.waiting) lines.push(`✋ ${w.name} (${w.room}): ${w.waitingFor}${w.answerable ? ' · dá para responder pelo escritório' : ''}`)
234 return lines.join('\n')
235}
236
237export function offlineText(url: string): string {
238 return `O Habblaud não respondeu em ${url}. Para subir: npm run docker:up na pasta do Habblaud.`
239}
240
241export function outdatedText(url: string): string {
242 return `O Habblaud em ${url} está numa versão sem a rota do mod. Para atualizar: git pull e npm run docker:up na pasta do Habblaud.`
243}
244
245// ---------------------------------------------------------------------------------------------
246// Estado do módulo (some num hot reload) e o que fala com o Claude Code
247// ---------------------------------------------------------------------------------------------
248
249let env: ModEnv | undefined
250/** Última gravação do arquivo de uso (chave = arquivo + valores), para não regravar o mesmo em menos de 10 s. */
251let lastWrite: { key: string; at: number } | undefined
252/** Texto da linha de status; null = ainda não mexemos nela nesta carga (a primeira chamada sempre passa). */
253let lastStatus: string | undefined | null = null
254let timer: { cancel: () => void } | undefined
255let timerMs = 0
256/** Uma pergunta por vez: um Habblaud lento não acumula perguntas. */
257let polling = false
258
259/** O ambiente da sessão, lido uma vez por carga (cada nome escrito por extenso, como a análise exige). */
260async function readEnv($: EngineInterface): Promise<ModEnv> {
261 if (env) return env
262 // No Windows, HOME só existe se alguém o definir: vale o USERPROFILE (a mesma ordem do servidor, HOME e depois homedir()).
263 const home = (await $.env.get('HOME'))?.trim() || (await $.env.get('USERPROFILE'))?.trim() || undefined
264 const configDir = configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), home)
265 const next: ModEnv = {
266 port: portOf(await $.env.get('HABBLAUD_PORT')),
267 usageDir: usageDirOf(await $.env.get('HABBLAUD_USAGE_DIR'), home),
268 }
269 if (configDir) {
270 next.configDir = configDir
271 const accountId = accountIdOf(configDir)
272 if (accountId) next.accountId = accountId
273 }
274 env = next
275 return next
276}
277
278/** Grava o uso desta conta (se houver janelas e algo mudou ou já passou o intervalo). Pode lançar: quem chama engole. */
279async function writeUsage($: EngineInterface, rateLimits: readonly SessionRateLimit[]): Promise<void> {
280 if (!rateLimits.length) return
281 const e = await readEnv($)
282 if (!e.configDir || !e.usageDir) return
283 const now = await $.clock.now()
284 const rec = usageRecord(rateLimits, e.configDir, now)
285 if (!rec) return
286 const file = `${e.usageDir}/${rec.accountId}.json`
287 const key = JSON.stringify([file, rec.configDir, rec.five_hour, rec.seven_day])
288 if (lastWrite && lastWrite.key === key && now - lastWrite.at >= 0 && now - lastWrite.at < MIN_REWRITE_MS) return
289 // $.fs.write cria a pasta se faltar e NÃO é atômico: o leitor do servidor ignora uma leitura pela
290 // metade e fica com o último registro bom (server/accounts/statusline.ts).
291 await $.fs.write(file, `${JSON.stringify(rec)}\n`)
292 lastWrite = { key, at: now }
293}
294
295/** GET /api/mod/summary com prazo de 2 s; nunca lança. */
296async function askHabblaud($: EngineInterface, port: number, query: string): Promise<Asked> {
297 let wait: { cancel: () => void } | undefined
298 const timeout = new Promise<undefined>((resolve) => {
299 wait = $.clock.after(FETCH_TIMEOUT_MS, () => resolve(undefined))
300 })
301 try {
302 const res = await Promise.race([$.http.fetch(`http://127.0.0.1:${port}/api/mod/summary${query}`, { headers: { accept: 'application/json' } }), timeout])
303 if (!res) return { kind: 'offline' }
304 // 404 JSON = um Habblaud de antes do mod (rota desconhecida); outro 404 qualquer = não é o Habblaud.
305 if (res.status === 404 && res.text.includes('rota desconhecida')) return { kind: 'outdated' }
306 const summary = res.ok ? parseSummary(res.text) : undefined
307 return summary ? { kind: 'ok', summary } : { kind: 'offline' }
308 } catch {
309 return { kind: 'offline' }
310 } finally {
311 wait?.cancel()
312 }
313}
314
315/** Troca a linha de status só quando o texto muda. */
316function setStatus($: EngineInterface, text: string | undefined): void {
317 if (text === lastStatus) return
318 lastStatus = text
319 $.ui.status(text)
320}
321
322/** (Re)agenda as perguntas: 5 s com o Habblaud no ar, 30 s fora do ar. */
323function schedule($: EngineInterface, ms: number): void {
324 if (timer && timerMs === ms) return
325 timer?.cancel()
326 timerMs = ms
327 timer = $.clock.every(ms, () => {
328 void poll($)
329 })
330}
331
332/** Uma rodada do aviso embaixo do prompt. Nunca lança. */
333async function poll($: EngineInterface): Promise<void> {
334 if (polling) return
335 polling = true
336 try {
337 const surfaces = await $.session.surfaces()
338 if (!surfaces.length) {
339 setStatus($, undefined)
340 return
341 }
342 const e = await readEnv($)
343 const params = new URLSearchParams()
344 if (e.accountId) params.set('account', e.accountId)
345 params.set('session', await $.session.id())
346 const asked = await askHabblaud($, e.port, `?${params.toString()}`)
347 if (asked.kind !== 'ok') {
348 setStatus($, undefined)
349 schedule($, OFFLINE_POLL_MS)
350 return
351 }
352 schedule($, POLL_MS)
353 setStatus($, statusText(asked.summary.waiting))
354 } catch {
355 // silêncio: a próxima rodada tenta de novo
356 } finally {
357 polling = false
358 }
359}
360
361/** O texto do /habblaud: o escritório inteiro (sem filtrar esta sessão, para os números baterem com a tela). */
362async function habblaudText($: EngineInterface): Promise<string> {
363 const e = await readEnv($)
364 const url = `http://localhost:${e.port}`
365 const asked = await askHabblaud($, e.port, '')
366 if (asked.kind === 'outdated') return outdatedText(url)
367 if (asked.kind === 'offline') return offlineText(url)
368 return summaryText(asked.summary, url)
369}
370
371export const register: Register = (on) => {
372 on('session.start', async ($, e, next) => {
373 try {
374 await $.command.register({ name: 'habblaud', description: 'Resumo do Habblaud: quantos agentes, quem trabalha e quem precisa de você' })
375 } catch {
376 // sem o comando, o resto segue
377 }
378 try {
379 const usage = await $.session.usage()
380 await writeUsage($, usage.rateLimits)
381 } catch {
382 // falha ao gravar = silêncio (o Habblaud continua com o último número que tinha)
383 }
384 try {
385 schedule($, POLL_MS)
386 } catch {
387 // sem o aviso embaixo do prompt, o resto segue
388 }
389 return next(e)
390 })
391
392 // Depois de cada turno e quando um limite anda um ponto. `e` traz os mesmos números de
393 // `$.session.usage()` naquele instante, então não há por que perguntar de novo.
394 on('session.measure', async ($, e, next) => {
395 try {
396 await writeUsage($, e.rateLimits)
397 } catch {
398 // falha ao gravar = silêncio
399 }
400 return next(e)
401 })
402
403 on('command.run', { command: 'habblaud' }, async ($) => {
404 try {
405 return { text: await habblaudText($) }
406 } catch {
407 return { text: offlineText(`http://localhost:${env?.port ?? DEFAULT_PORT}`) }
408 }
409 })
410}
411