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="Habblaud logo: a small pixel-art building" />
<h1 align="center">Habblaud</h1>
<b>The virtual office for your Claude Code (and Codex) agents.</b><br /> Each project becomes a room, and each agent becomes a pixel-art character that shows, in real time, what it's doing.
<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-ready-2496ed?logo=docker&logoColor=white" /> <img alt="Zero runtime dependencies" src="https://img.shields.io/badge/runtime%20dependencies-0-f08a3c" /> <img alt="Interface in Portuguese, English, Spanish and French" src="https://img.shields.io/badge/interface-PT%20%C2%B7%20EN%20%C2%B7%20ES%20%C2%B7%20FR-4aa8e8" />
<img src="docs/screenshots/office.gif" width="820" alt="Animation of a Habblaud room: characters typing at their desks, subagents with badges and activity bubbles" />
You open Claude Code in several projects, launch subagents, leave tasks running… and lose track of who's doing what. Habblaud turns all of that into an office you can take in at a glance: who's typing, who raised a hand because they need you, who delivered their work and left, who went for a coffee while waiting for the next instruction — and how much of each account you've already used in the 5-hour session and in the week.
And when two or more agents have nothing to do, the office gets a social life: each one has its own personality, and they watch soccer together in the lounge, play video games and ping-pong, gossip in the kitchen, freshen up at the mirror and bet coins on rock-paper-scissors. See Social life.
Everything runs on your machine, reading the files Claude Code itself already writes (plus only the alias lines in your shell, to give each account its letter). Nothing leaves the computer: Habblaud doesn't read credentials or make external calls.
Overview. The building has a lobby with elevators, a kitchen, restrooms and a lounge; each project with an open session gets its own room along the hallway. At the top, the counters and each account's usage; on the left, the rooms and agents; at the bottom, the activity feed.

<table> <tr> <td width="50%"><img src="docs/screenshots/agent-details.png" alt="Details drawer of an agent with tasks, subagents, timeline and stats" /></td> <td width="50%"><img src="docs/screenshots/night.png" alt="The office at night, with streetlights on and lit rooms" /></td> </tr> <tr> <td><b>Agent details:</b> current activity, tasks with progress, subagents, timeline and stats.</td> <td><b>Day and night:</b> the sky in the windows and the lighting follow local time.</td> </tr> </table>
<table> <tr> <td width="42%"><img src="docs/screenshots/collab.png" alt="Subagent delivering the result to the main agent while another agent asks for permission" /></td> <td width="58%"> <b>Collaboration in plain sight.</b> Subagents are coworkers with their own names and badges: they arrive by elevator, work in the project's room and, when they finish, walk over to the agent that called them to deliver the result (📦) before leaving.<br /><br /> <b>Needs you.</b> When an agent is waiting for a permission or an answer in the terminal, it rushes to its desk and raises a hand, with a blinking alert — and a notice pops up on screen (optionally with sound and a browser notification). You can approve, answer the question or send the next instruction right there. </td> </tr> </table>
<table> <tr> <td width="58%"> <b>Waiting on shell.</b> When the agent finishes its turn but leaves a command running (tests, build, deploy…), it doesn't wander off: it stays at its desk with an hourglass flipping over its head, the terminal showing progress and a bubble with the command and the elapsed time. And the wait turns into comedy: first it eats popcorn watching the terminal; after 3 min it crosses its arms and spins in its chair; after 10 min it gathers cobwebs; after 25 min it dozes off. When the command finishes, it stands up and celebrates with confetti — or gets a rain cloud, if it failed. In the sidebar list and in the details, a timer shows how long each command has been running. </td> <td width="42%"><img src="docs/screenshots/shell-wait.png" alt="Agent eating popcorn at its desk, with an hourglass over its head and the bubble 'Run the test suite · 1 min'; in the front row, a coworker has been waiting longer, with cobwebs on the chair and the terminal showing progress" /></td> </tr> </table>
The lights go out. When you close a project's last session, the last character walks to the light switch, turns off the lights and leaves by elevator — and the room is taken apart, becoming a garden until a new project arrives. If the garden ended up between two rooms, the farthest room moves there: it's set up in the empty spot, everyone walks over to it, and the old address goes dark and is taken apart. That way the building has no gaps and shrinks on its own.
| 1. The room is set up, still dark | 2. Someone turns on the lights and everyone works | 3. The last one leaves and turns off the lights | 4. The room becomes a garden |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
<table> <tr> <td width="34%"><img src="docs/screenshots/mobile.png" alt="Habblaud on a phone, with account usage at the top" /></td> <td> <b>On your phone too.</b> The interface adapts to small screens: account usage stays at the top, the room list becomes a drawer and the details open from the bottom up. See how to allow access over the local network in <a href="#open-on-your-phone-optional">Open on your phone</a>. </td> </tr> </table>
The images above use demo mode (made-up projects, people and accounts).
git clone https://github.com/marmottajr/habblaud.git
cd habblaud
npm install
npm installonly downloads build tools (Vite, TypeScript, tsx…). The Habblaud server has no runtime dependencies: it only uses Node's built-in modules.
Option A — Docker (recommended for keeping it always on)
npm run docker:up
The script detects your Claude Code accounts, mounts only the folders it needs (read-only), builds the image and starts the container — which restarts on its own along with Docker. Open http://localhost:4747.
Option B — Node, without Docker
npm run build
npm start
Open http://localhost:4747. For development, use npm run dev (server + Vite with hot reload).
Now open Claude Code in any project and watch your agent arrive by elevator. 🎉
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
Habblaud ships a mod (a plugin that runs inside Claude Code) and installs it in each account, through claude plugin itself, from this folder. With it:
/habblaud command becomes available in Claude Code;habblaud-permissoes plugin (see Answering from the office);habblaud-mensagens plugin (see Sending messages).It requires Claude Code 2.1.287 or newer (claude --version). The mod runs inside each session, with your permissions, and only accesses what is listed in mod/README.md; to check without running anything, claude plugin validate mod/habblaud shows the events it handles and the calls it makes. Sessions that are already open load the mod with /reload-plugins (or when reopened). If the account had the old setup (below), mod:install removes the tap and the hook from settings.json (with a backup), because the mod does the same thing. Check with npm run mod:status; to undo: npm run mod:uninstall.
The mod is loaded from this folder. If you move the folder, run
npm run mod:installagain.
Without mods, live usage and answering from the office come from two installers that edit each account's settings.json (with a backup first):
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
Your statusline keeps looking the same. To undo: npm run usage:uninstall and npm run hooks:uninstall. Both point to this folder: if you move the folder, run them again (until then, the accounts' statusline shows an error). When you update Claude Code, switch to the mod: npm run mod:install removes the old tap and hook (with a backup) so you don't end up with two of them capturing usage or answering the same request.
If you also use OpenAI's Codex (the codex CLI or the app), its sessions join the same office: each project is a room — the same room as Claude Code, when both are open in the same project — and each session is a character with the CODEX tag. Habblaud finds the Codex folder on its own (~/.codex, CODEX_HOME or ~/.codex*) and reads the conversations, without installing anything. To see Codex live (what it's doing right now, who needs you) and approve commands from the office, install the hooks:
npm run codex:install # acrescenta os hooks do Habblaud em ~/.codex/hooks.json (backup antes; os outros ficam)
npm run codex:status # confere
Then open Codex and approve the Habblaud hooks in /hooks: Codex only runs a new hook after you approve it (Habblaud never writes that approval). With Habblaud in Docker, to send messages to Codex sessions, also keep npm run codex:bridge running on the Mac (see Codex).
By default Habblaud only accepts connections from the computer it runs on. To open it on your phone (on the same Wi-Fi), with Docker:
printf 'HABBLAUD_BIND=0.0.0.0\n' > .env
npm run docker:up -- --no-build
Then open http://<ip-do-computador>:4747 on your phone (on macOS: ipconfig getifaddr en0; if it comes back empty, ipconfig getifaddr en1). In Node mode, use HABBLAUD_HOST=0.0.0.0 npm start.
⚠️ With this, any device on the network can see the agents' activity (commands, files, session titles). Use it only on trusted networks. The terminal (and with it, answering and sending messages from the office) stays off while the port is exposed. To go back: delete
.envand runnpm run docker:up -- --no-build.
Instead of exposing the main port, open a second port with a username, a password and HTTPS. Port 4747 stays on the computer only (hooks, plugins and your browser keep working without a login); the remote port goes to the network and every request on it requires a login. What separates the two is the port the connection arrived on, never the Host header. Nothing of this exists until you run the setup: without it, no extra port is published.
npm run remote:setup # usuário, senha, nome do endereço e (opcional) um endereço próprio
npm run docker:up
remote:setup writes only the password hash (scrypt) to .env, creates a local CA and the server certificate in ~/.habblaud/remote-tls, and leaves a service announcing <nome>.local on the network (mDNS; LaunchAgent on macOS, systemd on Linux). Open https://<nome>.local on your phone (with :4748 if port 443 was taken). The first time, accept the warning, tap Install the Habblaud certificate and trust it (iPhone: Settings › Profile Downloaded › Install, then Settings › General › About › Certificate Trust Settings; Android: Security › Encryption & credentials › Install a certificate › CA certificate), checking the fingerprint the setup showed.
localhost, .local names, your own address and private-network IPs. Even if its key leaked, a certificate for any other site would be rejected by the phone that trusts it. Changing your own address creates a new CA (install the certificate on the phone again). The CA key never goes into the container.meuclaude.home): goes into the certificate, but the phone only finds it with an entry in the router's DNS pointing to the computer's IP. <nome>.local works without configuring anything.HABBLAUD_REMOTE_ALLOW in .env accepts permissions, messages, terminal and spawn, separated by commas, or none (view only). The interactive terminal never works from the phone.terminal allowed): the box button in the top bar lists what each agent finished, with a summary and attachments (screenshots, PDFs, files it sent) that open with a tap.claude --bg, in the chosen folder), which shows up in the office and receives messages and permission requests through Habblaud. In a folder Claude Code doesn't trust yet, it opens the Claude app's new-session screen filled in, and you press Enter there. It comes turned off: it needs HABBLAUD_PTY=1 (the same opt-in as the interactive terminal; in Docker, put it in .env and keep npm run codex:bridge running on the computer) and, from the phone, spawn in HABBLAUD_REMOTE_ALLOW too.whisper-cli and ffmpeg (brew install whisper-cpp ffmpeg) and a multilingual model in ~/.local/share/whisper/ (ggml-small.bin gets more right than ggml-base.bin; the .en ones only understand English). Without them, the Dictate button doesn't show up. The voice never leaves the computer. HABBLAUD_WHISPER_BIN, HABBLAUD_FFMPEG_BIN, HABBLAUD_WHISPER_MODEL and HABBLAUD_DICTATION_LANGS (default pt,en) adjust it.tailscale serve --bg --https=443 https+insecure://127.0.0.1:443 publishes the remote port (with login) only to your devices, with a valid certificate at https://<computador>.<tailnet>.ts.net; add that name to HABBLAUD_ALLOWED_HOSTS. Always point it at the remote port, never at 4747 (which has no login).docker:up re-issues the server certificate when the new IP is missing or when it's 30 days from expiring, with the same CA (the phone keeps trusting it); the server swaps the certificate without restarting.npm run remote:setup -- --off and npm run docker:up.Without HTTPS the remote port doesn't start; HABBLAUD_REMOTE_INSECURE_HTTP=1 forces plain HTTP (password and cookie in the clear on the network). In Node mode: HABBLAUD_REMOTE_PORT and HABBLAUD_REMOTE_HOST (default 0.0.0.0:4748) and HABBLAUD_REMOTE_TLS_DIR (default ~/.habblaud/remote-tls/server).
The version in use shows up in the top bar, next to "Connected", and in Settings › About. Every 6 hours Habblaud checks GitHub for a new version (the releases of this repository). When there is one, the green New version badge appears in place of the number, along with a notice and a dot on the settings button. About has the link to what changed and the Check now button (each version's notes are also in CHANGELOG.md). To update:
git pull
npm install
npm run docker:up # ou: npm run build && npm start (e depois npm run mod:install)
If
git pullstops with "Your local changes to the following files would be overwritten by merge: package-lock.json", rungit checkout -- package-lock.jsonand try again: up to 0.4.0,npm installchanged that file.
docker:up also updates the mod in the accounts where it's already installed (it never installs it on its own) and tells you: "Mod updated to 0.3.0 on Account D; open sessions: /reload-plugins". In Node mode, run npm run mod:install after updating. To skip checking GitHub, use HABBLAUD_UPDATE_CHECK=0 (in .env, for Docker).
Habblaud was called CodeTown up to 0.3.2. After git pull, run once:
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, the accounts' Claude Code complains about the codetown marketplace (the mod/codetown* folders were renamed). docker:up warns you, but doesn't switch it on its own.codetown_codetown-data) is left untouched. After checking that the characters' names came over, delete it with docker volume rm codetown_codetown-data, and the old image with docker image rm codetown:local.~/.codetown becomes ~/.habblaud on its own (in the server, in docker:up and in mod:install).CODETOWN_* ones no longer work; rename them to HABBLAUD_* in .env (e.g. HABBLAUD_BIND). The server and docker:up warn you when they find an old one.github.com/marmottajr/habblaud (GitHub redirects the old address). To fix your clone: git remote set-url origin https://github.com/marmottajr/habblaud.git. The local folder can keep its old name.settings.json backups made before the switch keep the name 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, salas, linha do tempo e estatísticas)
docker image rm habblaud:local # apaga a imagem
rm -rf ~/.habblaud # apaga os dados locais (uso capturado, nomes, salas, linha do tempo e estatísticas)
Then just delete the project folder — run mod:uninstall first, or the accounts' Claude Code will start complaining about the missing marketplace. If you used the old way, also run npm run usage:uninstall (restores the original statusline) and npm run hooks:uninstall (removes the permission hook); without them, the accounts' statusline starts showing an error (and the hook, failing silently). mod:install, which removes the old tap and hook, and the old installers and uninstallers leave settings.json.habblaud-backup-<data> copies in each account's folder (e.g. ~/.claude/); delete them if you no longer need them.
| State | What you see |
|---|---|
| Working | At the desk, typing. The monitor and a bubble show the activity: 📖 reading, ✏️ editing, 💻 terminal, 🧪 tests, 🌐 searching… |
| Needs you | Rushes to the desk and raises a hand, with a blinking alert: it's waiting for a permission or an answer in the terminal. |
| Waiting on shell | Finished its turn but left a command running (tests, build, deploy…), so it stays at the desk with an hourglass flipping over its head and the terminal in progress: it eats popcorn while watching, after 3 min it crosses its arms and spins in its chair, after 10 min it gathers cobwebs and after 25 min it dozes off; when the command finishes, it stands up and celebrates with confetti (or gets a rain cloud, if it failed). After 40 s, if there are coworkers with nothing to do, it may head out to a hangout — with the hourglass over its head. |
| Idle | Finished its turn and wanders around: coffee in the kitchen, the water cooler, the restroom, the lounge couch, the phone on the beanbag, the mirror. With idle coworkers around, it joins a hangout: TV, video games, ping-pong, a kitchen chat, rock-paper-scissors (see Social life). After 10 min without moving, it dozes off — but a coworker may wake it up for a hangout. |
| Subagent done | Walks over to the agent that called it, delivers the result and leaves by elevator. |
| Session ended | Leaves; if it was the last one in the room, it turns off the lights on the way out. |
Each character has a unique Brazilian name (Marina, Henrique, Luan…) that stays as long as the session exists, and a colored chip with the account letter (C, D…).
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