바탕화면에 사는 픽셀 펫 클로: Claude가 생각하고, 도구를 쓰고, 허락을 기다리고, 일을 마칠 때마다 반응합니다. 마우스로 끌어 옮길 수 있습니다

| mod | 하는 일 | 명령 |
|---|---|---|
| usage-band | 프롬프트 바로 위에 Claude(5시간·주간·Fable 등 모델별 주간·컨텍스트)와 Codex 사용량 막대. 두 줄의 열이 맞춰져 있음 | /usagebar 두 줄 → 한 줄 → 숨김 |
| pocket-pet | 바탕화면에 사는 픽셀 펫 "클로"(주황 몸·민트 스카프). Claude가 생각·작업·허락 대기·완료할 때 반응하고, 마우스로 끌어 옮길 수 있음. 사용량 띠와는 따로 동작 | /pet 켜기, /pet pat 쓰다듬기, /pet 끄기 |
필요: Claude Code v2.1.287 이상 (claude --version). 터미널과 데스크톱 앱의 Code 탭에서 보입니다(VS Code 확장 채팅창에서는 안 그려짐).
다른 PC에서는 GitHub 저장소(kgrvGRECO/woo-mods)를 마켓플레이스로 추가합니다. Claude Code에서:
/plugin marketplace add kgrvGRECO/woo-mods
/plugin install usage-band@woo-mods
/plugin install pocket-pet@woo-mods
/reload-plugins
새 버전이 올라오면 /plugin marketplace update woo-mods → /plugin update pocket-pet@woo-mods(또는 usage-band) → /reload-plugins. 바탕화면 클로에는 Python 3의 pythonw가 필요합니다(없으면 /pet에서 안내가 뜹니다).
이 PC(개발 폴더 C:\dev\개발용\mods)에서는 폴더를 마켓플레이스로 씁니다: /plugin marketplace add C:\dev\개발용\mods 뒤 위와 같이 설치. 설정 바꾸기(선택): /plugin configure pocket-pet@woo-mods — 이름, 자동으로 띄우기(on·off), pythonw 경로
한 번만 시험해 보려면 설치 없이: claude --plugin-dir C:\dev\개발용\mods\pocket-pet --plugin-dir C:\dev\개발용\mods\usage-band
수정한 뒤 반영: 이 폴더에서 Claude Code로 고치면 저절로 반영됩니다. 프로젝트 훅(.claude/settings.json)이 고친 파일이 속한 mod를 tools/sync_mods.py로 플러그인 캐시에 복사하고 등록을 고쳐, 데스크톱 앱이 열린 Code 세션 모두에 다시 불러오기를 보냅니다(desktop/을 고치면 바탕화면 클로도 새 코드로 다시 뜹니다). 직접 편집기로 고쳤으면 한 번 실행하세요: python tools\sync_mods.py (하나만: python tools\sync_mods.py pocket-pet). 버전을 올리지 않아도 반영됩니다.
/reload-plugins를 해도 옛 코드가 다시 올라왔습니다. 그래서 동기화 스크립트는 지금 등록된 버전 폴더뿐 아니라 돌고 있는 세션이 쓰는 버전 폴더(Claude Code가 .in_use 표시를 남긴 곳) 전부에 최신을 넣습니다. 그러면 어느 세션이든 다시 불러올 때 새 코드가 올라갑니다./usage가 쓰는 사용량 API를 직접 받아 Fable 같은 모델별 주간 한도를 채우고 5시간·주간도 최신으로 맞춥니다. 자격 증명은 Claude Code가 핸들로 붙여 주므로 mod는 토큰을 보지 않습니다. 세션이 여럿 열려 있으면 한 세션이 받은 값을 나머지가 저장소에서 가져다 쓰므로 API는 전체로 1분에 한 번쯤만 부릅니다. API가 거절하면(429 등) 알려 준 시간만큼 쉬었다 다시 받고, 그동안 Fable 값이 15분 넘게 묵으면 "Fable 20분 전 기록 · API 429"처럼 까닭이 붙습니다(마지막 시도 결과는 ~/.claude/plugins/store/usage-band_*.json의 claudeFetch에도 남습니다). 로그인 없이 쓰거나 API를 못 받을 때만 상태줄(awesome-statusline)이 남기는 %TEMP%\.claude_usage_cache를 대신 읽고, 그마저 오래되면 "몇 분 전 기록"이 붙습니다.codex app-server)에 1분마다 물어서 받습니다. Codex가 /status에 보여 주는 것과 같은 값이고, 인증은 Codex가 스스로 하므로 토큰을 만지지 않습니다. node와 codex 명령이 있어야 하며(npm으로 Codex를 깔았으면 둘 다 있음), 못 받을 때만 이 PC의 %USERPROFILE%\.codex\sessions 기록(Codex를 마지막으로 쓴 시점의 값)을 읽고 "몇 분 전 기록"을 붙입니다. 리셋 시각이 지난 창은 0%로 표시합니다.pythonw가 PATH에 있어야 함, 없으면 /plugin configure pocket-pet@woo-mods에서 경로 지정). 마우스를 올리면 아래에 입력 영역이 열립니다: Code 또는 대화(일반 Claude 대화)를 고르고 적어서 Enter를 누르면 Claude 데스크톱 앱에서 이어집니다. Code에서는 폴더 칩을 눌러 열린 세션에 이어서(그 세션에 바로 들어감) 또는 새 세션 폴더(그 폴더에서 새 Code 세션)를 고릅니다. 열린 세션으로 보내기는 pocket-pet 0.5.0 이상이 돌고 있는 세션에서만 됩니다(목록에도 그런 세션만 나옴). 새 세션 폴더로 보내면 클로가 Claude Code CLI(claude)로 그 폴더에서 첫 턴을 화면 없이 돌린 뒤(보통 10~30초, 클로가 "생각하는 중"으로 보여 줌) 그 세션을 데스크톱 앱에 들여와 바로 엽니다. 첫 턴은 화면이 없어서 허락을 물을 수 없으므로, 읽기만으로 답할 수 있는 질문은 바로 답해 두고, 파일 수정·명령 실행이 필요한 요청은 계획만 적어 두었다가 앱에서 "진행"이라고 하면 시작합니다(그때부터는 평소처럼 허락을 묻습니다). CLI가 없으면 앱의 새 세션 작성 화면에 글만 채워 두므로 Enter로 시작하세요. CLI 경로를 직접 지정하려면 desktop.json에 "claude": "경로"를 적습니다. 말하기는 Windows 음성 입력(Win+H)을 켭니다(설정 → 시간 및 언어 → 음성에서 한국어 음성 입력이 켜져 있어야 함). 왼쪽 버튼으로 끌어 옮기고, 클릭하면 Claude 데스크톱 앱이 앞으로 나옵니다(꺼져 있으면 실행). 오른쪽 클릭하면 메뉴(Claude 열기 · 쓰다듬기 · 말풍선 끄고 켜기 · 크기 작게/보통/크게 · 끄기)가 나옵니다. 자리와 크기는 %USERPROFILE%\.claude\clo\desktop.json에 남습니다.python pocket-pet ools\make-frames.py <폴더>(Pillow 필요)를 실행하세요.api.anthropic.com/api/oauth/usage, Claude Code의 /usage와 같은 곳) 한 군데만 부르며, 자격 증명은 Claude Code가 핸들로 붙여 주어 mod는 토큰을 보지 않습니다. 실행하는 프로그램은 node(Codex 앱 서버에 한도를 묻는 작은 스크립트), codex app-server(그 스크립트가 띄웠다 닫음), PowerShell(큰 Codex 기록에서 마지막 줄만 뽑을 때, 바탕화면 클로를 띄울 때), pythonw(바탕화면 클로), 그리고 클로가 새 세션을 열 때의 claude -p입니다. 파일은 Codex 기록·상태줄 캐시 읽기, 클로 상태 파일(~/.claude/clo) 쓰기, 자체 저장소에 설정 저장. claude plugin validate .\usage-band 로 직접 확인할 수 있습니다.claude --debug의 로그를 Claude에게 보여 주세요.pocket-pet/desktop/fonts/LICENSE-neodgm.txt)입니다. 프로그램 안에서만 불러오며 Windows에 설치하지 않습니다. 사용량 띠의 데스크톱 패널도 같은 글꼴(woff2, usage-band/fonts/)을 그림 안에 넣어 씁니다.hooks/register.ts 293 lines1// pocket-pet: 바탕화면에 사는 픽셀 펫 "클로"(주황 몸에 민트 스카프)의 Claude Code 쪽 절반.
2//
3// 이 mod는 Claude Code 화면에 아무것도 그리지 않는다. Claude가 하는 일(생각·도구·허락 대기·
4// 완료·멈춤)을 세션마다 상태 파일(~/.claude/clo/sessions/<세션 ID>.json)에 적고, 바탕화면 클로
5// 프로그램(desktop/clo_pet.pyw)을 띄운다. 프로그램은 살아 있는 세션들의 파일을 읽어 반응한다
6// (허락 대기가 먼저, 그다음 가장 최근에 바뀐 세션). 창은 마우스로 끌어 옮길 수 있다.
7//
8// 바탕화면 클로의 입력 카드에서 이 세션을 골라 보내면, 클로가 ~/.claude/clo/inbox/<세션 ID>.json 에
9// 메시지를 남기고, 이 mod가 1초마다 확인해 사용자가 입력한 것처럼 이 세션에 넣는다.
10//
11// /pet 바탕화면 클로 켜기(이미 떠 있으면 화면 안으로 불러오기)
12// /pet pat 쓰다듬기
13// /pet 끄기 바탕화면 클로 끄기
14
15import type { EngineInterface, Register } from 'claude-code'
16
17type Mood = 'idle' | 'thinking' | 'working' | 'waiting' | 'done' | 'oops'
18type Command = 'show' | 'pat' | 'quit'
19
20/** 바탕화면 클로가 읽는 상태 파일의 모양 */
21export type PetState = {
22 v: 1
23 name: string
24 mood: Mood
25 /** 말풍선에 쓸 한 줄 */
26 detail: string
27 /** 읽기·찾기 도구 중인지(살펴보는 동작) */
28 isReading: boolean
29 toolCount: number
30 turnStartedAt: number
31 /** mood가 바뀐 시각(ms) */
32 at: number
33 /** 세션이 살아 있다는 표시(1분마다) */
34 alive: number
35 /** 세션의 작업 폴더: 바탕화면 클로에서 새 세션을 열 때 고를 수 있다 */
36 cwd?: string
37 /** 세션 제목: Claude 앱에 보이는 세션 제목(아직 없으면 첫 질문의 앞부분). 바탕화면 클로의 세션 목록에 보인다 */
38 title?: string
39 /** 바탕화면 클로가 남긴 메시지를 받을 수 있다(이 표시가 있는 세션만 '열린 세션' 목록에 나온다) */
40 canReceive?: boolean
41 command?: { id: number; kind: Command }
42}
43
44const HEARTBEAT_MS = 60_000
45const INBOX_MS = 1_000 // 바탕화면 클로가 남긴 메시지를 확인하는 간격
46const INBOX_FRESH_MS = 2 * 60_000 // 이보다 오래된 메시지는 넣지 않는다
47
48/** 바탕화면 클로가 이 세션 앞으로 남기는 메시지 */
49export type InboxMessage = { id: number; text: string; at: number }
50const READING_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'LS', 'NotebookRead'])
51
52// 모듈 상태(다시 불러오면 처음부터)
53let petName = '클로'
54let isAutoStart = true
55let python = ''
56let stateDir = ''
57let state: PetState = { v: 1, name: '클로', mood: 'idle', detail: '', isReading: false, toolCount: 0, turnStartedAt: 0, at: 0, alive: 0 }
58let isTurnRunning = false
59let toolsRunning = 0
60let commandId = 0
61let inboxDir = ''
62let lastInboxId = 0 // 이미 넣은(또는 시작 전에 있던) 메시지
63let isCheckingInbox = false
64
65async function homeOf($: EngineInterface): Promise<string> {
66 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
67}
68
69const isWindowsPath = (p: string) => p.includes('\\') || /^[A-Za-z]:/.test(p)
70const join = (base: string, ...parts: string[]) => [base, ...parts].join(isWindowsPath(base) ? '\\' : '/')
71
72async function save($: EngineInterface, patch: Partial<PetState>) {
73 const now = await $.clock.now()
74 if (patch.mood !== undefined && patch.mood !== state.mood) patch.at = now
75 state = { ...state, ...patch, name: petName, alive: patch.alive ?? now }
76 if (!stateDir) return
77 try {
78 // /clear 뒤에는 세션 ID가 바뀌므로 쓸 때마다 묻는다
79 await $.fs.write(join(stateDir, `${await $.session.id()}.json`), JSON.stringify(state))
80 } catch {
81 // 파일을 못 써도 Claude Code 작업은 계속된다
82 }
83}
84
85/** 바탕화면 클로 프로그램 띄우기. 이미 떠 있으면 프로그램이 알아서 하나만 남긴다 */
86async function launch($: EngineInterface): Promise<boolean> {
87 const script = join($.plugin.root, 'desktop', 'clo_pet.pyw')
88 const isWindows = isWindowsPath(script)
89 const argv = isWindows
90 ? [
91 'powershell.exe',
92 '-NoProfile',
93 '-NonInteractive',
94 '-Command',
95 `Start-Process -FilePath '${(python || 'pythonw').replace(/'/g, "''")}' -ArgumentList @('"${script.replace(/'/g, "''")}"')`,
96 ]
97 : ['sh', '-c', `nohup "${python || 'python3'}" "$1" >/dev/null 2>&1 &`, 'sh', script]
98 try {
99 const r = await $.process.run(argv, { timeoutMs: 15_000 })
100 return r.exitCode === 0
101 } catch {
102 return false
103 }
104}
105
106async function send($: EngineInterface, kind: Command) {
107 commandId += 1
108 await save($, { command: { id: (await $.clock.now()) * 10 + (commandId % 10), kind } })
109}
110
111async function readInbox($: EngineInterface): Promise<InboxMessage | undefined> {
112 if (!inboxDir) return undefined
113 const path = join(inboxDir, `${await $.session.id()}.json`)
114 try {
115 if (!(await $.fs.exists(path))) return undefined
116 const m = JSON.parse(await $.fs.read(path)) as InboxMessage
117 return typeof m.id === 'number' && typeof m.text === 'string' ? m : undefined
118 } catch {
119 return undefined // 쓰는 도중이면 다음에 다시
120 }
121}
122
123/** 바탕화면 클로가 남긴 새 메시지를 이 세션에 사용자 입력처럼 넣는다 */
124async function checkInbox($: EngineInterface) {
125 if (isCheckingInbox) return
126 isCheckingInbox = true
127 try {
128 const m = await readInbox($)
129 if (!m || m.id <= lastInboxId) return
130 lastInboxId = m.id
131 if (!m.text.trim() || (await $.clock.now()) - m.at > INBOX_FRESH_MS) return
132 await $.prompt.submit({ text: m.text, asUser: true })
133 } catch {
134 // 넣지 못해도 다음 메시지는 계속 받는다
135 } finally {
136 isCheckingInbox = false
137 }
138}
139
140/** 도구 호출을 사람이 읽는 한 줄로 */
141export function describeTool(tool: string, args: Record<string, unknown>): string {
142 const str = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
143 const base = (p: string) => p.split(/[\\/]/).pop() || p
144 const cut = (s: string, n = 40) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
145 switch (tool) {
146 case 'Read':
147 return `읽는 중: ${base(str('file_path'))}`
148 case 'Edit':
149 case 'MultiEdit':
150 case 'NotebookEdit':
151 return `고치는 중: ${base(str('file_path') || str('notebook_path'))}`
152 case 'Write':
153 return `쓰는 중: ${base(str('file_path'))}`
154 case 'Bash':
155 case 'PowerShell':
156 return `실행 중: ${cut(str('command').split('\n')[0] ?? '')}`
157 case 'Grep':
158 case 'Glob':
159 return `찾는 중: ${cut(str('pattern'), 30)}`
160 case 'WebSearch':
161 return `웹 검색: ${cut(str('query'), 30)}`
162 case 'WebFetch':
163 return `웹 페이지 읽는 중`
164 case 'Task':
165 case 'Agent':
166 return `도우미를 보내는 중`
167 case 'TodoWrite':
168 return `할 일 정리 중`
169 default: {
170 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
171 return m ? `${m[1]} 도구: ${m[2]}` : `${tool} 쓰는 중`
172 }
173 }
174}
175
176function durationText(ms: number): string {
177 const s = Math.max(1, Math.round(ms / 1000))
178 return s < 60 ? `${s}초` : `${Math.floor(s / 60)}분 ${s % 60}초`
179}
180
181export const register: Register = (on, options) => {
182 if (typeof options.pet_name === 'string' && options.pet_name.trim()) petName = options.pet_name.trim()
183 isAutoStart = options.auto_start !== 'off'
184 python = typeof options.python === 'string' ? options.python.trim() : ''
185
186 on('session.start', async ($, e, next) => {
187 const result = await next(e)
188 stateDir = join(await homeOf($), '.claude', 'clo', 'sessions')
189 await save($, { mood: 'idle', detail: '', cwd: e.cwd, canReceive: true })
190 // 바탕화면 클로가 남긴 메시지: 시작 전에 있던 것은 넣지 않는다
191 inboxDir = join(await homeOf($), '.claude', 'clo', 'inbox')
192 const old = await readInbox($)
193 if (old) lastInboxId = old.id
194 $.clock.every(INBOX_MS, () => {
195 void checkInbox($)
196 })
197 $.clock.every(HEARTBEAT_MS, () => {
198 void save($, {})
199 })
200 if (isAutoStart) void launch($)
201
202 try {
203 await $.command.register({
204 name: 'pet',
205 description: '바탕화면 클로 켜기 (pat 쓰다듬기 · 끄기)',
206 argumentHint: '[pat|끄기]',
207 immediate: true,
208 })
209 } catch {
210 // /pet 이 이미 있으면 명령 없이 상태만 적는다
211 }
212 return result
213 })
214
215 on('command.run', { command: 'pet' }, async ($, e) => {
216 const arg = e.args.trim().toLowerCase()
217 if (arg === 'pat' || arg === '쓰다듬기') {
218 await send($, 'pat')
219 } else if (arg === '끄기' || arg === 'quit' || arg === 'off' || arg === '숨기기') {
220 await send($, 'quit')
221 $.ui.toast(`${petName}가 쉬러 갔어요 (/pet 으로 부르기)`)
222 } else {
223 await send($, 'show')
224 const ok = await launch($)
225 $.ui.toast(ok ? `${petName}가 바탕화면에 나왔어요` : `${petName}를 띄우지 못했어요: Python(pythonw)이 있는지 확인해 주세요`)
226 }
227 return {}
228 })
229
230 // 세션이 끝나면 살아 있다는 표시를 지워 바탕화면 클로가 이 세션을 잊게 한다
231 on('session.end', async ($, e, next) => {
232 await save($, { mood: 'idle', detail: '', alive: 0 })
233 return next(e)
234 })
235
236 // ── Claude가 하는 일 ──────────────────────────────────────────────
237 // Claude 앱에 보이는 세션 제목: 질문을 받을 때·세션을 열 때 함께 온다(이름을 바꾸면 다음 질문 때 따라간다)
238 on('classic.UserPromptSubmit', async ($, e, next) => {
239 if (e.session_title && e.session_title !== state.title) await save($, { title: e.session_title })
240 return next(e)
241 })
242 on('classic.SessionStart', async ($, e, next) => {
243 const result = await next(e)
244 if (e.session_title && e.session_title !== state.title) await save($, { title: e.session_title })
245 return result
246 })
247
248 on('turn.start', async ($, e, next) => {
249 // 앱의 세션 제목이 아직 없으면 첫 질문을 제목으로(바탕화면 클로의 세션 목록에 보인다)
250 if (!state.title && e.text.trim()) {
251 const first = e.text.trim().split('\n')[0] ?? ''
252 state.title = first.length > 40 ? first.slice(0, 39) + '…' : first
253 }
254 isTurnRunning = true
255 toolsRunning = 0
256 await save($, { mood: 'thinking', detail: '', toolCount: 0, isReading: false, turnStartedAt: await $.clock.now() })
257 return next(e)
258 })
259
260 on('tool.call', async ($, e, next) => {
261 toolsRunning += 1
262 const words = (e.agentId ? '도우미: ' : '') + describeTool(String(e.tool), e as unknown as Record<string, unknown>)
263 const isReading = READING_TOOLS.has(String(e.tool))
264 if (state.mood === 'waiting') await save($, { toolCount: state.toolCount + 1, isReading })
265 else await save($, { mood: 'working', detail: words, toolCount: state.toolCount + 1, isReading })
266 try {
267 return await next(e)
268 } finally {
269 toolsRunning -= 1
270 // 동시에 돌던 도구가 모두 끝났을 때만 생각 중으로 돌아간다
271 if (toolsRunning === 0 && isTurnRunning && (state.mood === 'working' || state.mood === 'waiting')) {
272 await save($, { mood: 'thinking', detail: '', isReading: false })
273 }
274 }
275 })
276
277 on('classic.PermissionRequest', async ($, e, next) => {
278 await save($, { mood: 'waiting', detail: `허락이 필요해요 → ${describeTool(e.tool_name, (e.tool_input ?? {}) as Record<string, unknown>)}` })
279 return next(e)
280 })
281
282 on('turn.complete', async ($, e, next) => {
283 const result = await next(e)
284 if (e.agentId) return result // 도우미의 턴은 건너뛴다
285 isTurnRunning = false
286 const tools = state.toolCount > 0 ? ` · 도구 ${state.toolCount}번` : ''
287 if (e.reason === 'answer') await save($, { mood: 'done', detail: `다 했어요! ${durationText(e.durationMs)}${tools}`, isReading: false })
288 else if (e.reason === 'aborted') await save($, { mood: 'oops', detail: '멈췄어요. 다시 말해 주세요', isReading: false })
289 else await save($, { mood: 'oops', detail: '문제가 생겼어요… 다시 해 볼까요?', isReading: false })
290 return result
291 })
292}
293