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

Habblaud · KU Edition

고려대학교 캠퍼스 버전으로 꾸민 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"는 원본 프로젝트의 이름입니다.

KU Edition 전체 화면

설치 (Windows 11, Docker 없이)

필요한 것: 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에 남습니다.

켜고 끄는 방법은 두 가지입니다.

  • 화면에서: Habblaud 오른쪽 위 ⚙ 설정 → 맨 아래 자동 실행 → "컴퓨터를 켜면 자동 실행" 스위치. Windows에서, 이 PC의 브라우저로 열었을 때만 보입니다.
  • 명령으로:
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 포트에서 서버가 돌고 있으면 자동 실행은 두 번 띄우지 않고 그냥 끝납니다.

바뀐 점

캠퍼스 월드

  • 고려대 캠퍼스 배경: 본관(고딕 석조 건물, 중앙 탑에 호랑이 현수막과 동상), 좌우 별관, 정문, 광장과 호랑이 메달, 숲 배경, 자연계 캠퍼스 건물(유리 파빌리온, "KU" 잔디 글자), 남쪽 상가와 횡단보도, 그리고 길 양쪽 인도의 안암역 출구 2곳(6호선 표시)과 버스 정류장 2곳. 사무실은 광장 한가운데에 놓입니다.
  • 밤 조명: 밤에는 학교 곳곳의 가로등(본관 앞 깃발 가로등, 양쪽 인도의 가로등)이 켜지고, 본관·별관·자연계 건물·상가의 창문이 대부분(약 70%) 불을 밝힙니다. 어느 창이 켜질지는 위치로 정해져서 깜빡이지 않습니다.
  • 나무: 건물 위에 올라가지 않도록 건물 영역과 겹치는 자리에는 심지 않고, 벤치는 나무 뒤에 오도록 그리는 순서를 맞췄습니다.
  • 카메라: 월드 전체를 한 번에 볼 수 있습니다. − 키(또는 버튼)를 한 번 누르면 화면 너비에 맞춰 위쪽을 기준으로 전체를 보여 줍니다. 에이전트를 눌러 이동할 때는 최대 2배까지만 확대합니다.
  • 로고·아이콘: 헤더의 로고와 파비콘, 앱 아이콘을 고딕 석조 본관 모양으로 바꿨고, 헤더 왼쪽 위에 KU Edition 배지를 넣었습니다.

사무실 내부 (실제 연구실·열린도서관 참고)

  • 실제 대학원 연구실, 복도, 열린도서관 라운지, 1층 공동현관 사진과 고려대 강의실 사진을 눈으로 참고해 사무실 인테리어를 바꿨습니다. 사진 파일은 저장소에 넣지 않았고, 모두 코드로 그린 픽셀 아트입니다.
  • 프로젝트 방(연구실): 방마다 모습이 다릅니다. 실제 연구실 쪽(회색 비닐 장판, 너도밤나무 색 책상과 헤드폰 걸린 책장, 메시·흰 의자, 옷걸이, 소화기)과 원본 Habblaud 쪽(밝은 대리석 바닥, 방 색 러그, 흰색·나무·어두운 책상, 색색 의자)을 번갈아 섞었습니다. 벽 색·무늬(무늬목 포함), 반쯤 내린 롤 블라인드, 고려대 호랑이·공지 포스터는 공통입니다. 휴게 코너는 둥근 의자·푸프 대신 3인 소파와 큰 탁자(또는 의자 달린 긴 회의 탁자)로 하고, 방마다 냉장고와 책장을 두었습니다.
  • 복도: 연한 테라조 바닥과 짙은 판석, 벽면 소화기. (옛 보라색 러너 카펫은 없앴습니다.)
  • 1층 로비: 분홍빛 화강암 바닥과 화강암 벽, 벽 간판의 "고려대학교"(tools/ku-brand/make-signage.cjs), 트로피 진열장, 호랑이 그림, 큰 회의 탁자 2개와 의자 7개, 대기용 소파, 비상함·자동제세동기(AED)·소화기, 배너, 유리 난간.
  • 라운지·탕비실: 테라조 바닥, 긴 어두운 회의 테이블(두 개를 이어 붙임)과 검은 의자 4개, 큰 탁자 앞의 소파(푸프는 없앰), 서서 쓰는 PC 코너, 녹색·펩시 자판기, 검은 카페 의자와 너도밤나무 색 테이블.
  • 에이전트 외모: 이름이 한국 이름이라 피부색을 동아시아인 범위의 6가지 톤으로 좁혔고, 머리는 검정·짙은 갈색 위주(염색 갈색과 흰머리 약간), 눈동자는 짙은 갈색·검정으로 했습니다. 캠퍼스를 걷는 보행자도 같은 범위입니다. 전에 직접 꾸민 외모는 그대로입니다.
  • 화장실: 학교 화장실 사진을 참고해 벤치·화분·러그를 없애고, 회색 칸막이 2칸, 소변기 2개(칸막이 사이에 둠), 거울 달린 세면대 3개, 손 건조기, 안내문 게시판, 소화기만 남겼습니다. 에이전트가 화장실에 가면 가끔 소변기를 쓰고, 세면대에서 손을 씻은 뒤 손 건조기로 말립니다.

걸어 다니는 사람과 교통

  • 학생과 시민이 광장, 인도, 횡단보도뿐 아니라 잔디 위도 걷고, 건물 문으로 들어갔다 나오고, 벤치에 앉았다 일어납니다. (길 찾기는 A\* 알고리즘입니다.)
  • 시내버스·마을버스·승용차가 다닙니다. 보행자 신호가 켜지면 차가 서고, 버스는 정류장에서 승객을 태우고 내려 줍니다. 서쪽으로 가는 버스는 남쪽 인도(상가 쪽)의 정류장에, 동쪽으로 가는 버스는 북쪽 인도(캠퍼스 쪽)의 정류장에 섭니다. 정류장은 모두 횡단보도를 지난 뒤에 있습니다.
  • 사람들이 안암역 출구 계단을 걸어 내려가 몸이 계단 속으로 잠기듯 사라지고, 올라올 때는 계단에서 솟아오르듯 나타납니다(출구마다 따로). 정류장에서는 기다리다 버스에 타고, 버스에서 내려 걸어갑니다.
  • 양쪽 인도(캠퍼스 쪽과 건너편)에 가로수(가로수 화분 포함), 가로등, 소화전, 배전함, 지상 변압기를 놓았습니다.
  • 이 모든 동작은 꾸밈입니다. 실제 에이전트와는 연결되어 있지 않습니다.
  • 위 규칙은 테스트로 확인합니다(client/src/world/render/ku-sim.test.ts). 실제 지도에서 900초씩 돌렸을 때 차와 보행자의 충돌 0건, 횡단보도 위에서 차가 멈춘 시간 0초, 도달할 수 없는 문·벤치 0곳이었습니다.

한국어

  • 화면 전체 한국어 번역(shared/locales/ko.ts).
  • 에이전트 이름: 브라질식 이름 120개를 20~30대 한국 이름(남녀 60개씩, 예: 김서연·박도윤)으로 바꿨습니다. 원래 쓰던 이름은 같은 순서의 새 이름으로 옮겨 주는 스크립트가 있습니다(아래 참고).
  • 사교 생활의 금액: 가상 코인을 "천 원" 단위로 보여 줍니다(코인 1개 = 1천 원, 예: 105천 원). 한국어 화면에서만 바뀌고 다른 언어는 🪙 그대로입니다.

Windows 호환

  • 에이전트가 전달한 파일 링크가 Windows 경로(C:\...)에서도 열립니다(server/deliveries/files.ts).
  • macOS/Linux의 chmod 600/700에 해당하는 "나(현재 사용자)만 접근" 권한을 Windows의 icacls로 설정합니다(server/fs-private.ts). 원격 접속 비밀값, 인증서 개인키, 첨부 파일 폴더에 적용됩니다.
  • 서버 테스트가 Windows에서도 통과합니다. 다만 claude --bg 실행 테스트 1개는 sh 스크립트가 필요해서 Windows에서는 건너뜁니다.

세션 계보(포크) 표시

Claude Code에서 세션을 복사해 이어 간(fork) 경우, 어느 세션에서 갈라져 나왔는지 사이드바와 상세 서랍에 보입니다.

  • 사이드바 줄: ↳ 한지수에서 포크, 바로 위 원본이 이미 닫혔으면 ↳ “원본 제목”에서 포크 (원본: 한지수), 원본 쪽에는 ↳ 포크 2개의 원본. 이름에 마우스를 올리면 세션 id(앞 8자리)가 나옵니다.
  • 서랍의 세션 줄: 세션 id, 포크 관계, 포크 계보(원본 → 포크 → 포크의 포크). 원본이 사무실에 있으면 이름을 눌러 바로 이동합니다.
  • 원리: 포크된 세션의 기록 앞부분은 원본의 메시지 id를 그대로 복사해 가지고 있습니다. 같은 프로젝트 폴더의 세션 기록 앞부분(최대 192KB)을 비교해서 찾고, 파일이 만들어진 시각이 빠른 쪽을 원본으로 봅니다(server/sources/lineage.ts). 세션 기록은 읽기만 합니다.

에이전트 보관과 숨기기 (세션은 건드리지 않음)

모두 화면에서만 치우는 기능입니다. Claude Code 세션, 서버, 기록은 그대로입니다. 설정은 이 브라우저(localStorage)에 저장됩니다.

  • 보관: 일을 마친(유휴·완료·연결 끊김) 에이전트의 상세 서랍에서 보관 버튼을 누르면 사무실에서 사라집니다. 서버가 프로세스를 다시 띄워도 같은 세션이면 계속 숨겨집니다.
  • 다시 꺼내기: ⚙ 설정 → 보관·숨김에서 보관한 에이전트 목록의 다시 꺼내기(또는 모두 다시 꺼내기). 사이드바 맨 아래에 "화면에서 치운 에이전트 N명"이 보이고, 누르면 설정이 열립니다.
  • 오래 유휴인 에이전트 숨기기: 같은 곳에서 안 함 / 1일 / 3일 / 7일을 고릅니다(기본은 안 함). 마지막 실제 활동이 그만큼 지난 유휴·완료 에이전트만 숨기고, 다시 무언가 하면 저절로 돌아옵니다. 일하는 중이거나 응답을 기다리는 에이전트는 숨기지 않습니다.

계정 이름 바꾸기

⚙ 설정 → 계정 이름에서 계정("계정 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과 지정한 날짜의 타임라인 파일을 고칩니다.

알려진 한계

  • 상가에는 들어갈 수 없고, 앉은 모습은 정면 스프라이트 하나뿐입니다.
  • 설치형 앱(PWA) 창에서의 동작과 성능은 확인하지 못했습니다.
  • 인테리어는 사진을 보고 "분위기"를 옮긴 것입니다. 한글 간판·안내문 글자는 그리지 않았고, 실제 공간과 1:1로 같지는 않습니다.
  • 나무가 건물과 겹치는지 자동으로 검사하는 테스트는 없습니다. 건물 배치를 바꾸면 화면으로 확인하세요.
  • 코인을 천 원으로 바꾼 것은 한국어 화면의 표기만입니다. 계산 단위(코인 개수)는 원본 그대로입니다.
  • 원본의 CHANGELOG.md, CONTRIBUTING.md, docs/screenshots는 그대로 남아 있습니다. 원본의 다른 언어 README는 이 저장소에서 지웠습니다(원본 링크 참고).
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