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…

고려대학교 캠퍼스 버전으로 꾸민 Habblaud 개인용 포크입니다. Habblaud는 Claude Code(와 Codex) 에이전트를 픽셀 아트 사무실로 보여 주는 로컬 웹 앱입니다. Habblaud 자체의 소개·기능·사용법·설정은 원본 저장소를 보세요: https://github.com/marmottajr/habblaud 이 저장소에는 KU Edition의 설치 방법과 원본에서 바뀐 점만 적습니다.
기준 버전은 원본 0.11.0(커밋 4ccce25)이고, 라이선스는 원본과 같은 MIT입니다(LICENSE). 원작자(Márcio Junior)의 저작권 고지와 MIT 라이선스 전문은 LICENSE에 그대로 있습니다.
비공식 개인 프로젝트입니다. 고려대학교, Anthropic, 서울교통공사와 관련이 없고 승인이나 후원을 받은 것도 아닙니다. 학교 이름·호랑이 상징·건물 모습·지하철 표시는 직접 그린 픽셀 아트로 표현한 팬 작품이며, 사진이나 공식 로고·교표 파일은 쓰지 않았습니다. 관계자의 요청이 있으면 해당 부분을 바로 삭제하거나 고칩니다. "Claude Code"는 Anthropic의 제품 이름이고, "Habblaud"는 원본 프로젝트의 이름입니다.

필요한 것: Node.js 22.12 이상, Git, Chrome 또는 Edge. 에이전트 목록은 PC에 설치된 Claude Code의 세션을 읽어서 채워집니다. 개발·확인은 Node 24, Windows 11에서 했습니다.
PowerShell에서 실행하세요. npm이 아니라 npm.cmd를 쓰는 이유는 PowerShell 실행 정책이 npm.ps1을 막는 PC가 많기 때문입니다.
git clone https://github.com/hecksin-byte/habblaud-ku-edition.git
cd habblaud-ku-edition
npm.cmd install
npm.cmd run build
$env:HABBLAUD_LANG = 'ko'
node dist\server\index.js
브라우저에서 <http://127.0.0.1:4747> 을 엽니다. 에이전트가 없어도 화면만 보고 싶으면 <http://127.0.0.1:4747/?mock=1> 로 열면 가상 에이전트가 나옵니다. HABBLAUD_LANG=ko는 에이전트 활동 문구를 한국어로 만드는 서버 설정입니다. 화면의 한국어는 브라우저·설정 화면의 언어 선택을 따릅니다.
코드를 바꾼 뒤에는 npm.cmd run build를 다시 하고 서버를 재시작해야 반영됩니다. 클라이언트만 바꿨다면 재빌드만으로 충분합니다.
Windows에 로그인할 때 서버가 저절로 뜨게 하는 설정입니다. 작업 스케줄러에 Habblaud Server 항목을 만들고, 서버는 숨김 창으로 실행됩니다. 로그는 %USERPROFILE%\.habblaud\server.log, server.err.log에 남습니다.
켜고 끄는 방법은 두 가지입니다.
powershell -ExecutionPolicy Bypass -File scripts\windows\autostart.ps1 -Action on # 켜기(항목이 없으면 만듦)
powershell -ExecutionPolicy Bypass -File scripts\windows\autostart.ps1 -Action off # 끄기(항목은 남기고 비활성화)
powershell -ExecutionPolicy Bypass -File scripts\windows\autostart.ps1 -Action status # 상태를 JSON으로 확인
끄더라도 지금 떠 있는 서버는 꺼지지 않습니다. 이미 4747 포트에서 서버가 돌고 있으면 자동 실행은 두 번 띄우지 않고 그냥 끝납니다.
− 키(또는 버튼)를 한 번 누르면 화면 너비에 맞춰 위쪽을 기준으로 전체를 보여 줍니다. 에이전트를 눌러 이동할 때는 최대 2배까지만 확대합니다.tools/ku-brand/make-signage.cjs), 트로피 진열장, 호랑이 그림, 큰 회의 탁자 2개와 의자 7개, 대기용 소파, 비상함·자동제세동기(AED)·소화기, 배너, 유리 난간.client/src/world/render/ku-sim.test.ts). 실제 지도에서 900초씩 돌렸을 때 차와 보행자의 충돌 0건, 횡단보도 위에서 차가 멈춘 시간 0초, 도달할 수 없는 문·벤치 0곳이었습니다.shared/locales/ko.ts).105천 원). 한국어 화면에서만 바뀌고 다른 언어는 🪙 그대로입니다.C:\...)에서도 열립니다(server/deliveries/files.ts).chmod 600/700에 해당하는 "나(현재 사용자)만 접근" 권한을 Windows의 icacls로 설정합니다(server/fs-private.ts). 원격 접속 비밀값, 인증서 개인키, 첨부 파일 폴더에 적용됩니다.claude --bg 실행 테스트 1개는 sh 스크립트가 필요해서 Windows에서는 건너뜁니다.Claude Code에서 세션을 복사해 이어 간(fork) 경우, 어느 세션에서 갈라져 나왔는지 사이드바와 상세 서랍에 보입니다.
↳ 한지수에서 포크, 바로 위 원본이 이미 닫혔으면 ↳ “원본 제목”에서 포크 (원본: 한지수), 원본 쪽에는 ↳ 포크 2개의 원본. 이름에 마우스를 올리면 세션 id(앞 8자리)가 나옵니다.server/sources/lineage.ts). 세션 기록은 읽기만 합니다.모두 화면에서만 치우는 기능입니다. Claude Code 세션, 서버, 기록은 그대로입니다. 설정은 이 브라우저(localStorage)에 저장됩니다.
⚙ 설정 → 계정 이름에서 계정("계정 A", "Codex" 등)마다 원하는 이름을 붙일 수 있습니다(최대 24자). 상단 사용량 표시줄, 사이드바, 에이전트 서랍, 알림, 툴팁에 모두 반영되고, 비워 두면 원래 이름으로 돌아갑니다. 화면에서만 바뀌는 별명이라 서버와 Claude Code 설정은 그대로이며, 이 브라우저에 저장됩니다.
위의 "컴퓨터를 켜면 자동 실행"입니다. 설정 화면 스위치 + GET/POST /api/autostart(이 PC에서만 허용, 휴대폰 접속은 거부) + scripts/windows/autostart.ps1.
| 하는 일 | 명령 |
|---|---|
캠퍼스 장면(ku.ts)을 다시 생성 (ku.ts는 직접 고치지 않음) | python tools/ku-gen/gen_ku.py |
로고·아이콘 PNG를 다시 그림 (--ico <경로>를 붙이면 .ico도) | node tools/ku-brand/make-brand.cjs |
| 저장된 에이전트 이름을 한국 이름으로 옮김(서버를 멈춘 뒤, 저장소 루트에서) | node scripts/migrate-names-ko.cjs <타임라인 날짜 YYYY-MM-DD ...> |
| 테스트 / 타입 검사 | npm.cmd test / npm.cmd run typecheck |
이름 이동 스크립트는 %USERPROFILE%\.habblaud\backup-names-<날짜>에 원본을 백업하고, names.json과 지정한 날짜의 타임라인 파일을 고칩니다.
CHANGELOG.md, CONTRIBUTING.md, docs/screenshots는 그대로 남아 있습니다. 원본의 다른 언어 README는 이 저장소에서 지웠습니다(원본 링크 참고).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