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.

고려대학교 캠퍼스 버전으로 꾸민 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 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