SLOPSHOPPER

Habblaud

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…

newcommandstatusnetworktimer
v0.11.0MITupdated 2026-10-13Gabolonhez/AgenticOffice/mod/habblaud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · habblaud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /habblaud ⎿ habblaud: O Habblaud não respondeu em http://localhost:4747. Para subir: npm run docker:up na pasta do Habblaud. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

Contents

What it looks like

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.

Habblaud overview: building with lobby, kitchen, restrooms, lounge and project rooms; sidebar with rooms and agents; account usage at the top; activity feed at the bottom

<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 dark2. Someone turns on the lights and everyone works3. The last one leaves and turns off the lights4. The room becomes a garden
Room being set up, with the furniture appearing and the lights offLit room with three agents working at their desksRoom with the lights off and the furniture still in place, after everyone leftThe room's lot turned into 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).

Installation

Requirements

  • macOS (tested) or Linux.
  • Claude Code installed, with one or more accounts (2.1.287 or newer for the mod).
  • Node.js 22.12+ and npm (developed with Node 24; Docker already uses Node 24).
  • Docker Desktop (or Docker Engine with Compose v2), if you're going to run it in a container.

1. Get the project

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

npm install only downloads build tools (Vite, TypeScript, tsx…). The Habblaud server has no runtime dependencies: it only uses Node's built-in modules.

2. Start Habblaud

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

3. Install the Habblaud mod in Claude Code (recommended)

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:

  • each account's 5-hour and weekly usage shows up live in Habblaud;
  • the terminal shows a line when another session needs you (permission or answer);
  • the /habblaud command becomes available in Claude Code;
  • you can approve or deny permission requests from the office ("Do you want to…") and answer the agent's questions, with the habblaud-permissoes plugin (see Answering from the office);
  • you can send messages to the agent from the office, as if you were typing in its terminal, with the 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:install again.

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.

4. Codex in the office (optional)

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

Open on your phone (optional)

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 .env and run npm run docker:up -- --no-build.

Phone with login and HTTPS (approving requests)

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.

  • Restricted CA: it only signs 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.
  • Own address (e.g. 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.
  • What the phone can do: by default, approve and deny permission requests. 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.
  • Deliveries (phone, with 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.
  • New agent (the + button): starts a Claude Code session in the background on the computer (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.
  • Dictation (in New agent and in the message box): needs 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.
  • Away from home (Tailscale): with Tailscale on the computer and on the phone, 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).
  • Session: 30 days; changing the password (running the setup again) ends all of them, and Settings › Phone access › Sign out ends the one on that device. After 5 wrong passwords in a row, each new attempt waits from 1 s up to 5 min.
  • IP changed? 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.
  • Turn off: 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).

Updating

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 pull stops with "Your local changes to the following files would be overwritten by merge: package-lock.json", run git checkout -- package-lock.json and try again: up to 0.4.0, npm install changed 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).

Coming from CodeTown

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: until you run 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.
  • Docker: the old volume (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.
  • Local folder: ~/.codetown becomes ~/.habblaud on its own (in the server, in docker:up and in mod:install).
  • Browser: preferences and coins move to the new keys the first time the page opens.
  • Variables: the 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.
  • Repository: it's now 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>.

Uninstalling

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.

How to use

What each character is doing

StateWhat you see
WorkingAt the desk, typing. The monitor and a bubble show the activity: 📖 reading, ✏️ editing, 💻 terminal, 🧪 tests, 🌐 searching…
Needs youRushes 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 shellFinished 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.
IdleFinished 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 doneWalks over to the agent that called it, delivers the result and leaves by elevator.
Session endedLeaves; 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…).

Interface

  • Top: counters (rooms, agents, working, subagents, shells running, need you) and a per-account usage card. The shells counter only shows up while some command is running; click it
Source 1 files
hooks/register.ts 411 lines
1// 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