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