클로드 펫 개요21단계안 될 때

클로드 펫

AI가 일을 끝내면
유령이 알려줍니다.

코딩을 몰라도 한 시간이면 만듭니다.

클로드 펫 유령 캐릭터

Windows 10 · 11 지원 · Node.js와 Python 필요 · 비용 0원

네 가지 표정.

지금 무슨 일이 일어나는지 한눈에.

대기
클로드가 대기 중입니다
아무 일도 없을 때
작업 중
클로드가 작업하고 있어요
작업이 시작되는 순간
질문
질문있습니다!
내 대답을 기다릴 때 · 유령이 좌우로 흔들립니다
완료
작업 완료! 끝냈어요
일이 끝난 순간 · 유령이 튀어오릅니다

신호를 받는 경로는 세 갈래입니다. ① 크롬 확장이 claude.ai 화면을 보다가 답변이 끝나면 알림 ② 파이썬 감시자가 클로드 앱의 모든 작업 목록을 보다가 상태가 바뀌면 알림 ③ 주소창에 주소를 쳐서 직접 알림. 셋 다 status.txt 라는 메모지 한 장에 상태를 적고, 유령은 그 메모지만 1초마다 읽습니다.

시작하기 전에.

윈도우 컴퓨터 한 대면 됩니다.

필요한 것
윈도우 10 · 11
맥은 이 방식 그대로는 안 됩니다
설치할 것
Node.js · Python
둘 다 공식 사이트에서 무료
준비할 것
캐릭터 그림 1장
배경이 투명한 PNG / WEBP
걸리는 시간
40분 ~ 1시간
코드는 복사 버튼으로 붙여넣기만

21단계로 만들기.

코드는 [복사] 를 누르고 메모장에 붙여넣으세요.

01

폴더 만들기

모든 파일이 여기 한 곳에 모입니다

  1. 내 PC → C: 드라이브를 엽니다
  2. 빈 곳에 마우스 오른쪽 클릭 → 새로 만들기 → 폴더
  3. 폴더 이름을 정확히 이렇게 짓습니다대문자·띄어쓰기 없이, 가운데는 하이픈(-)claude-pet
주의이 경로가 코드 안에 그대로 들어갑니다. C:\claude-pet 이 아니면 감시자가 파일을 못 찾습니다.
02

Node.js 설치하기

유령 창을 띄우는 엔진입니다

  1. nodejs.org 에 접속합니다https://nodejs.org
  2. 왼쪽의 LTS 버튼을 누릅니다"권장" 또는 "LTS" 라고 적힌 쪽입니다
  3. 내려받은 파일을 실행하고 계속 [Next] 를 누릅니다기본값 그대로 두면 됩니다
  4. 설치가 끝나면 컴퓨터를 한 번 껐다 켭니다이걸 안 하면 다음 단계에서 "npm을 찾을 수 없다"고 나옵니다
03

Python 설치하기

화면을 읽는 감시자를 돌립니다

  1. python.org 의 다운로드 페이지로 갑니다https://www.python.org/downloads/
  2. 노란 [Download Python] 버튼을 누릅니다
  3. 설치 첫 화면 맨 아래 체크박스를 반드시 켭니다이걸 놓치면 나중에 pip 명령이 통째로 안 먹습니다Add Python to PATH
  4. [Install Now] 를 누릅니다
주의"Add Python to PATH" 는 설치 첫 화면에만 나옵니다. 놓쳤다면 제거하고 다시 설치하는 편이 빠릅니다.
04

캐릭터 그림 준비하기

유령이 될 그림 한 장

  1. 마음에 드는 캐릭터 그림을 구합니다배경이 투명한 PNG / WEBP 가 가장 예쁩니다
  2. 이름을 정확히 이렇게 바꿔 C:\claude-pet 에 넣습니다코드가 이 이름으로 찾습니다pet.webp
  3. PNG 를 쓴다면이름을 pet.png 로 두고, 8단계 index.html 안의 ./pet.webp 를 ./pet.png 로 고치세요
  4. 트레이 아이콘용 파일도 만듭니다구글에 "webp to ico" 검색 → 변환해서 같은 폴더에pet.ico
주의pet.ico 가 없으면 시계 옆 아이콘이 안 뜹니다. 이 시스템은 그 아이콘으로만 조작하므로 꼭 만드세요.
05

package.json 만들기

이 폴더가 어떤 프로그램인지 알려주는 명찰

package.json C:\claude-pet 에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "name": "claude-pet",
  "version": "1.0.0",
  "description": "클로드 작업 알람 데스크탑 펫",
  "main": "main.js",
  "scripts": {
    "start": "electron main.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^32.x.x"
  }
}
14줄
주의메모장에서 저장할 때 파일 형식을 반드시 [모든 파일]로 바꾸세요. [텍스트 문서]로 두면 package.json.txt 가 됩니다.
06

Electron 설치하기

유령 창을 띄워주는 부품을 내려받습니다

  1. C:\claude-pet 폴더를 엽니다
  2. 탐색기 주소창을 클릭해 글자를 지우고 cmd 입력 + 엔터그 폴더 위치에서 검은 창이 열립니다cmd
  3. 검은 창에 입력하고 엔터1~3분 걸립니다. 글자가 주르륵 올라가도 정상입니다npm install
  4. node_modules 폴더가 생기면 성공
도움말node_modules 는 용량이 수백 MB입니다. 정상이니 지우지 마세요.
07

main.js 만들기

창 모양, 시계 옆 아이콘, 창 흔들기를 담당합니다

main.js C:\claude-pet 에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
// 클로드 작업 알람 펫 - Electron 창 뼈대 + 트레이 메뉴
// 이 파일은 '창'과 '시계 옆 아이콘'만 담당합니다. 화면 꾸미기는 index.html 에서 합니다.

const { app, BrowserWindow, Tray, Menu, screen, shell, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn, execFile } = require('child_process');

const APP_NAME = '클로드 펫';

// 작업표시줄·알림에 'Electron' 대신 '클로드 펫'으로 보이게 합니다
app.setName(APP_NAME);
app.setAppUserModelId('claude.pet.agnes');
const FOLDER = __dirname;
const ICON = path.join(FOLDER, 'pet.ico');
const MUTE_FILE = path.join(FOLDER, 'mute.txt');   // on 이 적혀 있으면 알림을 쉽니다

// Windows 시작프로그램 폴더에 놓을 바로가기 경로
const STARTUP_LNK = path.join(
  app.getPath('appData'),
  'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup',
  APP_NAME + '.lnk'
);

let win = null;
let tray = null;
let helpers = [];        // 같이 켜고 같이 끄는 파이썬 프로그램들

/* ---------- 파이썬 도우미 켜기 / 끄기 ---------- */
function startHelper(script) {
  const full = path.join(FOLDER, script);
  if (!fs.existsSync(full)) {
    console.warn('없는 파일이라 건너뜀:', script);
    return;
  }
  function launch(cmd, args) {
    const child = spawn(cmd, args, { cwd: FOLDER, windowsHide: true, stdio: 'ignore' });
    child.on('error', () => {
      if (cmd === 'python') launch('py', [full]);   // python 이 없으면 py 로 재시도
    });
    helpers.push(child);
  }
  launch('python', [full]);
}

function stopHelpers() {
  helpers.forEach((c) => {
    try {
      // 자식까지 확실히 종료 (/T 하위 프로세스, /F 강제)
      execFile('taskkill', ['/pid', String(c.pid), '/T', '/F']);
    } catch (e) { /* 이미 꺼졌으면 무시 */ }
  });
  helpers = [];
}

/* ---------- 창 ---------- */
function createWindow () {
  win = new BrowserWindow({
    width: 270,
    height: 158,
    transparent: true,
    frame: false,
    alwaysOnTop: true,
    resizable: false,
    skipTaskbar: true,        // 작업표시줄에 안 보이게 (조작은 시계 옆 아이콘으로)
    icon: ICON,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  win.loadFile('index.html');

  // 창을 닫아도 앱은 트레이에 남습니다 (종료는 트레이 메뉴에서)
  win.on('close', (e) => {
    if (!app.isQuiting) {
      e.preventDefault();
      win.hide();
      refreshMenu();
    }
  });
}

/* ---------- 창 흔들기 (윈도우 애니메이션 설정과 무관하게 동작) ----------
   CSS 애니메이션은 윈도우의 '애니메이션 효과' 설정이 꺼져 있으면 멈춥니다.
   그래서 창 자체의 위치를 잠깐 움직여서 눈에 띄게 합니다. */
let shaking = false;

const SHAKE_PATTERNS = {
  // 질문: 좌우로 크게 흔들림
  question: [[-14, 0], [14, 0], [-13, 0], [13, 0], [-10, 0], [10, 0],
             [-7, 0], [7, 0], [-4, 0], [4, 0], [-2, 0], [2, 0], [0, 0]],
  // 완료: 위로 튀어올랐다 내려옴
  done:     [[0, -18], [0, -22], [0, -10], [0, 3], [0, -8], [0, 2], [0, -3], [0, 0]]
};

const SHAKE_STEP_MS = 45;

function shakeWindow (kind) {
  if (!win || win.isDestroyed() || !win.isVisible()) return;
  if (shaking) return;                       // 이미 흔드는 중이면 무시 (원래 위치를 잃지 않도록)

  const seq = SHAKE_PATTERNS[kind];
  if (!seq) return;

  const [ox, oy] = win.getPosition();        // 원래 자리를 기억해 둡니다
  shaking = true;
  let i = 0;

  function step () {
    if (!win || win.isDestroyed()) { shaking = false; return; }
    if (i >= seq.length) {
      win.setPosition(ox, oy);               // 반드시 제자리로 되돌립니다
      shaking = false;
      return;
    }
    const d = seq[i++];
    win.setPosition(ox + d[0], oy + d[1]);
    setTimeout(step, SHAKE_STEP_MS);
  }

  step();
}

// 화면(index.html)에서 상태가 바뀔 때 보내주는 신호를 받습니다
ipcMain.on('pet-shake', (e, kind) => shakeWindow(kind));

/* ---------- 화면 오른쪽 아래로 옮기기 ---------- */
function moveToBottomRight () {
  if (!win) return;
  const area = screen.getPrimaryDisplay().workArea;
  const [w, h] = win.getSize();
  win.setPosition(area.x + area.width - w - 16, area.y + area.height - h - 16);
  win.show();
}

/* ---------- 알림 쉬기 (mute) ----------
   '/작업끝' 이라고 하면 클로드가 mute.txt 를 켜고, 감시자·중계서버·펫이 모두 조용해집니다.
   시계 옆 아이콘 메뉴에서도 직접 켜고 끌 수 있습니다. */
function isMuted () {
  try {
    return fs.readFileSync(MUTE_FILE, 'utf-8').trim().toLowerCase() === 'on';
  } catch (e) {
    return false;
  }
}

function setMuted (on) {
  try {
    fs.writeFileSync(MUTE_FILE, on ? 'on' : 'off', 'utf-8');
  } catch (e) {
    console.error('알림 설정 저장 실패:', e);
  }
  refreshMenu();
}

/* ---------- 시작 시 자동 실행 ---------- */
function isAutoStartOn () {
  try {
    return fs.existsSync(STARTUP_LNK);
  } catch (e) {
    return false;
  }
}

function setAutoStart (on) {
  try {
    if (on) {
      shell.writeShortcutLink(STARTUP_LNK, 'create', {
        target: 'wscript.exe',
        args: '"' + path.join(FOLDER, 'start_claude_pet.vbs') + '"',
        cwd: FOLDER,
        icon: ICON,
        iconIndex: 0,
        description: '클로드 작업 알람 펫'
      });
    } else if (fs.existsSync(STARTUP_LNK)) {
      fs.unlinkSync(STARTUP_LNK);
    }
  } catch (e) {
    console.error('자동 실행 설정 실패:', e);
  }
  refreshMenu();
}

/* ---------- 트레이 메뉴 ---------- */
function refreshMenu () {
  if (!tray) return;

  const menu = Menu.buildFromTemplate([
    { label: '클로드 펫', enabled: false },
    { type: 'separator' },
    {
      label: '펫 보이기',
      type: 'checkbox',
      checked: !!(win && win.isVisible()),
      click: (item) => {
        if (!win) return;
        if (item.checked) win.show(); else win.hide();
        refreshMenu();
      }
    },
    {
      label: '화면 오른쪽 아래로 옮기기',
      click: moveToBottomRight
    },
    { type: 'separator' },
    {
      label: '알림 쉬기 (조용히)',
      type: 'checkbox',
      checked: isMuted(),
      click: (item) => setMuted(item.checked)
    },
    {
      label: 'Windows 시작 시 자동 실행',
      type: 'checkbox',
      checked: isAutoStartOn(),
      click: (item) => setAutoStart(item.checked)
    },
    {
      label: '펫 폴더 열기',
      click: () => shell.openPath(FOLDER)
    },
    { type: 'separator' },
    {
      label: '종료',
      click: () => {
        app.isQuiting = true;
        app.quit();
      }
    }
  ]);

  tray.setContextMenu(menu);
  tray.setToolTip('클로드 펫');
}

function createTray () {
  tray = new Tray(ICON);
  refreshMenu();
  // 아이콘을 두 번 누르면 펫을 보이거나 숨깁니다
  tray.on('double-click', () => {
    if (!win) return;
    if (win.isVisible()) win.hide(); else win.show();
    refreshMenu();
  });
}

/* ---------- 시작 ---------- */
// 두 번 실행되는 것을 막습니다
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  app.quit();
} else {
  app.on('second-instance', () => {
    if (win) { win.show(); refreshMenu(); }
  });

  app.whenReady().then(() => {
    // 펫을 새로 켤 때는 항상 알림이 켜진 상태로 시작합니다.
    // (전날 꺼둔 것을 잊어버려 알림을 통째로 놓치는 일을 막습니다)
    try { fs.writeFileSync(MUTE_FILE, 'off', 'utf-8'); } catch (e) {}

    startHelper('claude_bridge.py');     // 중계 서버
    startHelper('cowork_watcher.py');    // 화면 감시자
    createWindow();
    createTray();
  });

  // 펫을 종료하면 파이썬 둘도 같이 끕니다
  app.on('will-quit', stopHelpers);
}

app.on('window-all-closed', () => {
  // 창이 닫혀도 트레이에 남아 있으므로 바로 끝내지 않습니다
});
277줄
도움말shakeWindow 부분이 핵심입니다. 윈도우의 "애니메이션 효과" 가 꺼져 있으면 CSS 움직임이 전부 죽기 때문에, 창 자체를 움직여서 눈에 띄게 합니다.
08

index.html 만들기

유령의 생김새와 말풍선 4종을 그립니다

index.html C:\claude-pet 에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>Claude Pet</title>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
  <style>
    /* ===== mono 디자인 토큰 ===== */
    :root {
      --c-primary:   #37F712;   /* 완료 - 매트릭스 그린 */
      --c-danger:    #FF2157;   /* 오류 */
      --c-question:  #C084FC;   /* 질문 - 보라 */
      --c-question-b:#A855F7;   /* 질문 테두리 */
      --c-muted:     #A8A29E;   /* 대기 텍스트 */
      --c-label:     #A8A29E;   /* 라벨 */
      --c-surface:   rgba(10, 10, 10, 0.88);
      --c-line:      rgba(168, 162, 155, 0.28);
      --radius:      3px;       /* mono = 각진 모서리 */
      --font-mono:   'JetBrains Mono', 'Space Mono', 'D2Coding', 'Malgun Gothic', Consolas, monospace;
    }

    body {
      -webkit-app-region: drag;      /* 창 전체를 잡고 끌 수 있음 */
      margin: 0;
      overflow: hidden;
      height: 100vh;
      background: transparent;
      font-family: var(--font-mono);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: flex-end;
      user-select: none;
    }

    /* ===== 말풍선 = 터미널 카드 ===== */
    .bubble {
      -webkit-app-region: no-drag;   /* 글씨는 클릭/복사 가능 */
      box-sizing: border-box;
      max-width: 250px;
      background: var(--c-surface);
      backdrop-filter: blur(10px);
      -webkit-backdrop-filter: blur(10px);
      border: 1px solid var(--c-line);
      border-radius: var(--radius);
      padding: 7px 11px 8px;
      margin-bottom: 10px;
      transition: border-color .18s ease, box-shadow .18s ease;
    }

    .label {
      font-size: 8px;
      font-weight: 700;
      letter-spacing: 0.16em;
      color: var(--c-label);
      margin-bottom: 4px;
      display: flex;
      align-items: center;
      gap: 6px;
    }

    /* 상태 점 */
    .dot {
      width: 5px;
      height: 5px;
      border-radius: 50%;
      background: var(--c-muted);
      flex: none;
    }

    .msg {
      font-size: 11px;
      font-weight: 500;
      line-height: 1.4;
      letter-spacing: -0.01em;
      color: var(--c-muted);
      word-break: keep-all;
      white-space: pre-wrap;
    }

    .caret {
      display: inline-block;
      width: 7px;
      margin-left: 3px;
      color: inherit;
      animation: blink 1.1s steps(1, end) infinite;
    }

    @keyframes blink {
      0%, 49%   { opacity: 1; }
      50%, 100% { opacity: 0; }
    }

    /* ===== 상태별 스타일 ===== */
    .bubble.is-done {
      border-color: var(--c-primary);
      box-shadow: 0 0 14px rgba(55, 247, 18, 0.28), inset 0 0 20px rgba(55, 247, 18, 0.06);
    }
    .bubble.is-done .msg,
    .bubble.is-done .label { color: var(--c-primary); }
    .bubble.is-done .dot {
      background: var(--c-primary);
      box-shadow: 0 0 7px var(--c-primary);
    }

    .bubble.is-question {
      border-color: var(--c-question-b);
      box-shadow: 0 0 16px rgba(168, 85, 247, 0.38), inset 0 0 22px rgba(168, 85, 247, 0.08);
    }
    .bubble.is-question .msg,
    .bubble.is-question .label { color: var(--c-question); }
    .bubble.is-question .dot {
      background: var(--c-question);
      box-shadow: 0 0 8px var(--c-question);
    }

    /* 알림을 쉬는 중 — 눈에 띄지 않게 흐리게 */
    .bubble.is-muted { border-color: rgba(168, 162, 155, 0.18); opacity: 0.55; }
    .bubble.is-muted .msg,
    .bubble.is-muted .label { color: var(--c-muted); }
    .bubble.is-muted .dot { background: var(--c-muted); opacity: 0.5; }

    .bubble.is-error { border-color: var(--c-danger); }
    .bubble.is-error .msg,
    .bubble.is-error .label { color: var(--c-danger); }
    .bubble.is-error .dot { background: var(--c-danger); }

    /* ===== 펫 ===== */
    .pet-container {
      display: flex;
      justify-content: center;
      margin-bottom: 12px;
    }

    .pet-image {
      width: 42px;
      height: auto;
      -webkit-app-region: drag;      /* 캐릭터를 잡아도 창 이동 */
      animation: float 3s ease-in-out infinite;
      filter: drop-shadow(0 3px 7px rgba(0, 0, 0, 0.45));
    }

    @keyframes float {
      0%   { transform: translateY(0); }
      50%  { transform: translateY(-5px); }
      100% { transform: translateY(0); }
    }

    /* 완료 순간 한 번 튀어오르는 반응 */
    .pet-image.pop { animation: pop .55s ease-out, float 3s ease-in-out infinite .55s; }
    @keyframes pop {
      0%   { transform: translateY(0)     scale(1); }
      35%  { transform: translateY(-11px) scale(1.10); }
      70%  { transform: translateY(0)     scale(0.97); }
      100% { transform: translateY(0)     scale(1); }
    }

    /* 질문이 뜨는 순간 펫이 좌우로 흔들림 */
    .pet-image.shake { animation: shake 0.7s ease-in-out, float 3s ease-in-out infinite 0.7s; }
    @keyframes shake {
      0%, 100% { transform: translateX(0) rotate(0deg); }
      15%      { transform: translateX(-6px) rotate(-7deg); }
      30%      { transform: translateX(6px)  rotate(7deg); }
      45%      { transform: translateX(-5px) rotate(-5deg); }
      60%      { transform: translateX(5px)  rotate(5deg); }
      80%      { transform: translateX(-2px) rotate(-2deg); }
    }

    /* 모션을 줄이는 설정을 켠 경우 애니메이션 중지 */
    @media (prefers-reduced-motion: reduce) {
      .pet-image, .pet-image.pop, .pet-image.shake, .caret { animation: none; }
    }
  </style>
</head>
<body>

  <div class="bubble" id="bubble">
    <div class="label"><span class="dot" id="dot"></span><span id="label">CLAUDE :: 대기</span></div>
    <div class="msg"><span id="msg">클로드가 대기 중입니다</span><span class="caret" id="caret">_</span></div>
  </div>

  <div class="pet-container">
    <img src="./pet.webp" alt="클로드 펫" class="pet-image" id="pet">
  </div>

  <script>
    const fs   = require('fs');
    const path = require('path');
    const { ipcRenderer } = require('electron');

    const STATUS_FILE = path.join(__dirname, 'status.txt');
    const TITLE_FILE  = path.join(__dirname, 'title.txt');
    const MUTE_FILE   = path.join(__dirname, 'mute.txt');

    const bubble = document.getElementById('bubble');
    const labelEl = document.getElementById('label');
    const msgEl   = document.getElementById('msg');
    const caretEl = document.getElementById('caret');
    const petEl   = document.getElementById('pet');

    // 상태별 표시 문구 (한글)
    const VIEW = {
      waiting: { cls: '',         label: 'CLAUDE :: 대기',    msg: '클로드가 대기 중입니다',        caret: true  },
      working: { cls: '',         label: 'CLAUDE :: 작업 중', msg: '클로드가 작업하고 있어요',      caret: true  },
      done:    { cls: 'is-done',  label: 'CLAUDE :: 완료',    msg: '작업 완료! 클로드가 끝냈어요',  caret: false },
      question:{ cls: 'is-question', label: 'CLAUDE :: 질문', msg: '질문있습니다!',                caret: false },
      muted:   { cls: 'is-muted', label: 'CLAUDE :: 알림 끔', msg: '알림을 쉬는 중입니다',          caret: false },
      error:   { cls: 'is-error', label: 'CLAUDE :: 오류',    msg: '상태 파일을 읽지 못했습니다',   caret: false }
    };

    // 알림을 쉬는 중인지 확인합니다 (mute.txt 에 on 이 적혀 있으면 쉼)
    function isMuted() {
      try {
        return fs.readFileSync(MUTE_FILE, 'utf-8').trim().toLowerCase() === 'on';
      } catch (e) {
        return false;
      }
    }

    let last = null;
    let resetTimer = null;

    // 현재 채팅 제목을 읽어옵니다 (감시자가 title.txt 에 적어둠)
    function readTitle() {
      try {
        const t = fs.readFileSync(TITLE_FILE, 'utf-8').trim();
        if (!t) return '';
        return t.length > 14 ? t.slice(0, 14) + '…' : t;
      } catch (e) {
        return '';
      }
    }

    // 완료 표시 후 5초 뒤 스스로 대기 상태로 복귀합니다.
    // 파이썬 브리지를 안 거치는 경로(노트북버전 클로드가 직접 기록)에서도
    // 말풍선이 계속 떠 있지 않도록 하는 안전장치입니다.
    function scheduleReset() {
      if (resetTimer) clearTimeout(resetTimer);
      resetTimer = setTimeout(function () {
        try {
          const now = fs.readFileSync(STATUS_FILE, 'utf-8').trim().toLowerCase();
          if (now === 'done') {
            fs.writeFileSync(STATUS_FILE, 'waiting', 'utf-8');
          }
        } catch (e) {
          // 파일 접근 충돌 시 무시 (다음 완료 때 다시 시도)
        }
      }, 5000);
    }

    function render(state) {
      if (state === last) return;
      const v = VIEW[state] || VIEW.waiting;

      bubble.className = 'bubble' + (v.cls ? ' ' + v.cls : '');

      const title = readTitle();
      // 질문일 때는 어느 작업인지 라벨에, 완료일 때는 말풍선 문구에 넣습니다
      labelEl.textContent = (state === 'question' && title)
        ? v.label + ' · ' + title
        : v.label;

      let text = v.msg;
      if (state === 'done' && title) text = title + ' 끝냈어요!';
      msgEl.textContent = text;
      caretEl.style.display = v.caret ? 'inline-block' : 'none';

      // 완료로 바뀌는 순간에만 펫이 한 번 튀어오름
      if (state === 'done' && last !== 'done') {
        petEl.classList.remove('shake');  // 이전 질문의 흔들림 클래스를 반드시 지웁니다
        petEl.classList.remove('pop');
        void petEl.offsetWidth;          // 애니메이션 재시작 트릭
        petEl.classList.add('pop');
        ipcRenderer.send('pet-shake', 'done');   // 창 자체도 튀어오름
        scheduleReset();                 // 5초 뒤 자동 복귀 예약
      }

      // 질문으로 바뀌는 순간 펫이 좌우로 흔들림 (자동 복귀는 하지 않음)
      if (state === 'question' && last !== 'question') {
        petEl.classList.remove('pop');    // 이전 완료의 튀어오름 클래스를 반드시 지웁니다
        petEl.classList.remove('shake');
        void petEl.offsetWidth;
        petEl.classList.add('shake');
        ipcRenderer.send('pet-shake', 'question'); // 창 자체도 좌우로 흔들림
      }

      last = state;
    }

    // 캐시 문제를 피하려고 브라우저 fetch 대신 Node.js 로 파일을 직접 읽습니다
    function tick() {
      // 알림을 쉬는 중이면 상태 파일과 무관하게 '알림 끔' 만 보여줍니다
      if (isMuted()) { render('muted'); return; }
      try {
        const raw = fs.readFileSync(STATUS_FILE, 'utf-8').trim().toLowerCase();
        if (raw === 'done' || raw === 'waiting' || raw === 'working' || raw === 'question') {
          render(raw);
        } else {
          render('waiting');
        }
      } catch (e) {
        render('error');
      }
    }

    tick();
    setInterval(tick, 1000);
  </script>

</body>
</html>
311줄 · 가장 길지만 대부분 색과 크기를 정하는 부분입니다
09

status.txt 만들기

지금 상태를 적어두는 메모지 한 장

  1. 메모장을 새로 엽니다
  2. 아래 한 단어만 적습니다다른 글자·띄어쓰기·줄바꿈 없이waiting
  3. 파일 → 다른 이름으로 저장 → C:\claude-pet 선택
  4. 파일 형식을 [모든 파일]로 바꾸고 이름을 적어 저장status.txt
10

claude_bridge.py 만들기

크롬 확장이 보낸 신호를 받는 중계 서버

claude_bridge.py C:\claude-pet 에 저장 (형식은 [모든 파일])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# -*- coding: utf-8 -*-
# 클로드 작업 알람 펫 - 브리지 서버
# 포트 5001 (기존 유령펫의 5000과 분리)

import http.server
import socketserver
import threading

PORT = 5001
STATUS_FILE = r"C:\claude-pet\status.txt"
MUTE_FILE = r"C:\claude-pet\mute.txt"     # on 이 적혀 있으면 알림을 쉽니다
RESET_SECONDS = 5.0

_timer = None


def is_muted():
    try:
        with open(MUTE_FILE, "r", encoding="utf-8") as f:
            return f.read().strip().lower() == "on"
    except Exception:
        return False


def write_status(value):
    try:
        with open(STATUS_FILE, "w", encoding="utf-8") as f:
            f.write(value)
        return True
    except Exception as e:
        print("[에러] status.txt 기록 실패:", e)
        return False


def reset_status():
    if write_status("waiting"):
        print("[상태 초기화] %s초 경과, 펫을 대기 상태로 되돌렸습니다." % int(RESET_SECONDS))


def fire_done():
    """done 기록 후 RESET_SECONDS 뒤 waiting 복귀. 연속 호출 시 타이머를 새로 잡는다."""
    global _timer
    if is_muted():
        print("[알림 꺼둠] 신호는 받았지만 펫을 울리지 않습니다.")
        return
    if write_status("done"):
        print("[상태 갱신] status.txt = done  (펫 알림 전송)")
    if _timer is not None:
        _timer.cancel()
    _timer = threading.Timer(RESET_SECONDS, reset_status)
    _timer.daemon = True
    _timer.start()


class BridgeHandler(http.server.SimpleHTTPRequestHandler):

    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")

    def do_POST(self):
        try:
            length = int(self.headers.get("Content-Length", 0))
            body = self.rfile.read(length)
            print("[신호 수신] POST:", body[:120])

            if b"done" in body:
                fire_done()

            self.send_response(200)
            self._cors()
            self.end_headers()
            self.wfile.write(b"OK")
        except Exception as e:
            print("[에러] 처리 중 문제가 생겼습니다:", e)

    def do_GET(self):
        # 테스트용: 브라우저 주소창에 http://localhost:5001/done 입력하면 즉시 알림
        try:
            if self.path.startswith("/done"):
                print("[신호 수신] GET /done  (수동 트리거)")
                fire_done()
                self.send_response(200)
                self._cors()
                self.send_header("Content-Type", "text/plain; charset=utf-8")
                self.end_headers()
                self.wfile.write("펫에게 완료 알림을 보냈습니다.".encode("utf-8"))
                return

            self.send_response(200)
            self._cors()
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.end_headers()
            self.wfile.write("claude-pet bridge alive".encode("utf-8"))
        except Exception as e:
            print("[에러] GET 처리 실패:", e)

    def do_OPTIONS(self):
        self.send_response(200)
        self._cors()
        self.end_headers()

    # 터미널이 지저분해지지 않도록 기본 접속 로그는 숨김
    def log_message(self, fmt, *args):
        return


if __name__ == "__main__":
    write_status("waiting")
    print("클로드 펫 브리지 서버가 포트 %d 에서 대기 중입니다..." % PORT)
    print("테스트: 브라우저에서 http://localhost:%d/done 접속" % PORT)
    try:
        with socketserver.TCPServer(("127.0.0.1", PORT), BridgeHandler) as httpd:
            httpd.serve_forever()
    except OSError:
        print("포트 %d 가 이미 쓰이고 있습니다. 다른 브리지가 돌고 있어 이 창은 종료합니다." % PORT)
117줄
11

uiautomation 설치하기

앱 화면의 글자를 읽는 부품입니다

  1. 검은 창(cmd)을 엽니다시작 버튼 옆 검색창에 cmd 입력
  2. 입력하고 엔터pip install uiautomation
  3. "Successfully installed" 가 나오면 성공
12

cowork_watcher.py 만들기

클로드 앱의 모든 작업을 지켜보는 감시자

cowork_watcher.py C:\claude-pet 에 저장 (형식은 [모든 파일])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# -*- coding: utf-8 -*-
# Cowork(Claude 데스크톱 앱) 감시자  v3
#
# v2 와 달라진 점
#   - 화면에 열어둔 작업 하나가 아니라, 왼쪽 목록의 "모든 작업"을 감시합니다.
#   - 목록의 상태 글자를 읽습니다:  유휴 / 실행 중 / 입력 대기 중 / 답변 대기 중
#   - "지금 그 상태인가" 가 아니라 "상태가 방금 바뀌었는가" 로 판단합니다.
#     (오래 멈춰 있던 작업 때문에 말풍선이 계속 떠 있는 일을 막습니다)
#   - 말풍선에 어느 작업인지 제목을 함께 보여줍니다.
#
# 필요: pip install uiautomation
# 실행: python C:\claude-pet\cowork_watcher.py

import io
import sys
import time

FOLDER = r"C:\claude-pet"
STATUS_FILE = FOLDER + r"\status.txt"
TITLE_FILE = FOLDER + r"\title.txt"
LOG_FILE = FOLDER + r"\watcher_log.txt"
MUTE_FILE = FOLDER + r"\mute.txt"     # 이 파일에 on 이 적혀 있으면 알림을 쉽니다

WINDOW_NAME = "Claude"
WINDOW_CLASS = "Chrome_WidgetWin_1"

# 왼쪽 작업 목록에 붙는 상태 꼬리표
RUN_PREFIXES = ["실행 중 "]
INPUT_PREFIXES = ["입력 대기 중 ", "답변 대기 중 "]
IDLE_PREFIXES = ["유휴 "]

# 목록을 못 읽었을 때만 쓰는 예비 수단 (열어둔 화면 기준)
FALLBACK_WORKING = ["Claude가 응답 중입니다"]
FALLBACK_QUESTION = ["질문 중"]

POLL = 2.0
MAX_DEPTH = 30
MAX_NODES = 4000
LOG_KEEP = 300

# ---------- 떨림 방지 ----------
# 작업이 도중에 잠깐 멈추면 목록 꼬리표가 "실행 중" -> "유휴" 로 바뀌었다가 되돌아옵니다.
# 그 순간을 완료로 착각해 한 작업에 여러 번 알림이 울리는 것을 막습니다.
# 상태가 바뀐 뒤 아래 시간만큼 "그대로 유지될 때만" 진짜 변화로 인정합니다.
START_CONFIRM = 4.0    # 시작·질문 — 짧은 작업도 놓치지 않게 짧게
END_CONFIRM = 15.0     # 완료 — 중간에 잠깐 멈춘 것을 걸러내려고 길게
GONE = "__gone__"      # 목록에서 사라진 상태를 나타내는 표시


def now():
    return time.strftime("%H:%M:%S")


_log_buf = []


def log(msg):
    line = "[{0}] {1}".format(now(), msg)
    print(line)
    sys.stdout.flush()
    _log_buf.append(line)
    if len(_log_buf) > LOG_KEEP:
        del _log_buf[0:len(_log_buf) - LOG_KEEP]
    try:
        with io.open(LOG_FILE, "w", encoding="utf-8") as f:
            f.write("\n".join(_log_buf))
    except Exception:
        pass


def write_file(path, value):
    try:
        with io.open(path, "w", encoding="utf-8") as f:
            f.write(value)
        return True
    except Exception as e:
        log("파일 기록 실패 {0}: {1}".format(path, e))
        return False


def is_muted():
    """알림 쉬는 중인지 확인합니다. 파일이 없으면 평소대로 알립니다."""
    try:
        with io.open(MUTE_FILE, "r", encoding="utf-8") as f:
            return f.read().strip().lower() == "on"
    except Exception:
        return False


def announce(state, title):
    """펫에 상태와 제목을 전달합니다. 알림을 쉬는 중이면 아무것도 하지 않습니다."""
    if is_muted():
        return False
    write_file(TITLE_FILE, title or "")
    write_file(STATUS_FILE, state)
    return True


# ---------- 중복 실행 방지 ----------
# 포트 하나를 잡아두고, 이미 잡혀 있으면 다른 감시자가 돌고 있다는 뜻이므로 조용히 끝냅니다.
import socket

LOCK_PORT = 5011
_lock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
    _lock.bind(("127.0.0.1", LOCK_PORT))
    _lock.listen(1)
except OSError:
    log("이미 다른 감시자가 실행 중입니다. 이 창은 종료합니다.")
    sys.exit(0)


try:
    import uiautomation as auto
except ImportError:
    log("uiautomation 이 설치돼 있지 않습니다.  ->  pip install uiautomation")
    time.sleep(6)
    sys.exit(1)

auto.SetGlobalSearchTimeout(1)


def find_window():
    try:
        root = auto.GetRootControl()
        for w in root.GetChildren():
            try:
                if (w.Name or "").strip() == WINDOW_NAME and (w.ClassName or "") == WINDOW_CLASS:
                    return w
            except Exception:
                continue
    except Exception:
        pass
    return None


def classify(name):
    """목록 항목 이름을 (상태, 제목) 으로 나눕니다. 해당 없으면 None."""
    for p in INPUT_PREFIXES:
        if name.startswith(p) and len(name) > len(p):
            return ("input", name[len(p):].strip())
    for p in RUN_PREFIXES:
        if name.startswith(p) and len(name) > len(p):
            return ("running", name[len(p):].strip())
    for p in IDLE_PREFIXES:
        if name.startswith(p) and len(name) > len(p):
            return ("idle", name[len(p):].strip())
    return None


def scan(win):
    """창을 한 번 훑어 작업 목록 상태와 예비 신호를 돌려줍니다."""
    sessions = {}
    fb = {"working": False, "question": False}
    count = [0]

    def walk(node, depth):
        if depth > MAX_DEPTH or count[0] > MAX_NODES:
            return
        count[0] += 1
        try:
            nm = (node.Name or "").strip()
        except Exception:
            return

        if nm:
            c = classify(nm)
            if c:
                st, title = c
                # 같은 작업이 여러 번 잡히면 더 '활동적인' 쪽을 남깁니다
                rank = {"idle": 0, "running": 1, "input": 2}
                if title not in sessions or rank[st] > rank[sessions[title]]:
                    sessions[title] = st
            else:
                if not fb["question"]:
                    for m in FALLBACK_QUESTION:
                        if m in nm:
                            fb["question"] = True
                            break
                if not fb["working"]:
                    for m in FALLBACK_WORKING:
                        if m in nm:
                            fb["working"] = True
                            break

        try:
            children = node.GetChildren()
        except Exception:
            return
        for ch in children:
            walk(ch, depth + 1)

    walk(win, 0)
    return sessions, fb


def main():
    log("=" * 46)
    log("Cowork 감시자 v3 시작 (모든 작업 감시)")
    log("목록 꼬리표: " + " / ".join(RUN_PREFIXES + INPUT_PREFIXES + IDLE_PREFIXES))

    log("떨림 방지: 시작 {0}초 / 완료 {1}초 유지될 때만 알립니다".format(
        int(START_CONFIRM), int(END_CONFIRM)))

    stable = {}        # 제목 -> 확정된 상태 (알림의 기준)
    pending = {}       # 제목 -> [바뀐 상태, 처음 본 시각]
    first_scan = True
    fb_state = "idle"  # 예비 수단이 쓰일 때의 상태
    warned = False

    while True:
        win = find_window()

        if win is None:
            if not warned:
                log("Claude 앱 창을 찾지 못했습니다. 앱이 켜지면 이어서 감시합니다.")
                warned = True
            time.sleep(POLL * 2)
            continue

        if warned:
            log("Claude 앱 창을 찾았습니다.")
            warned = False

        try:
            cur, fb = scan(win)
        except Exception as e:
            log("화면 읽기 오류: {0}".format(e))
            time.sleep(POLL * 2)
            continue

        # ---------- 목록을 못 읽은 경우: 예비 수단 ----------
        if not cur:
            new_fb = "question" if fb["question"] else ("working" if fb["working"] else "idle")
            if new_fb != fb_state:
                if new_fb == "question":
                    announce("question", "")
                    log("[예비] 질문 감지 -> question")
                elif new_fb == "working":
                    announce("working", "")
                    log("[예비] 응답 시작 -> working")
                elif fb_state in ("working", "question"):
                    announce("done", "")
                    log("[예비] 완료 -> done")
                fb_state = new_fb
            time.sleep(POLL)
            continue

        # ---------- 첫 스캔은 기준만 잡고 알리지 않습니다 ----------
        if first_scan:
            stable = dict(cur)
            pending = {}
            first_scan = False
            log("작업 {0}개를 기준으로 잡았습니다: {1}".format(
                len(cur), ", ".join(sorted(cur.keys()))[:120]))
            time.sleep(POLL)
            continue

        # ---------- 지금 화면의 상태 (사라진 작업은 GONE 으로) ----------
        seen = dict(cur)
        for title in stable:
            if title not in seen:
                seen[title] = GONE

        now_t = time.time()
        events = []      # (우선순위, 상태, 제목)
        settled = []     # 확정된 (제목, 새 상태)

        for title, st in seen.items():
            was = stable.get(title)

            # 확정된 상태 그대로면 흔들림이 멎은 것 -> 대기 중이던 변화는 취소
            if st == was:
                pending.pop(title, None)
                continue

            # '일이 끝난 방향' 인지에 따라 기다리는 시간이 다릅니다.
            # 질문(input)은 끝난 것이 아니라 나를 기다리는 것이므로 빨리 알립니다.
            finishing = (was in ("running", "input") and st in ("idle", GONE))
            need = END_CONFIRM if finishing else START_CONFIRM

            p = pending.get(title)
            if p is None or p[0] != st:
                pending[title] = [st, now_t]        # 변화를 처음 봤습니다. 시계 시작
                continue
            if now_t - p[1] < need:
                continue                            # 아직 유지 시간이 안 찼습니다

            # 여기까지 왔으면 진짜 변화입니다
            pending.pop(title, None)
            settled.append((title, st))

            if st == "input":
                events.append((3, "question", title))
            elif st == "running":
                events.append((1, "working", title))
            elif finishing:
                events.append((2, "done", title))

        for title, st in settled:
            if st == GONE:
                stable.pop(title, None)
            else:
                stable[title] = st

        if events:
            events.sort(reverse=True)
            _, state, title = events[0]
            label = {"question": "질문 발생", "working": "작업 시작", "done": "작업 완료"}[state]
            # 알림을 쉬는 중이어도 위의 상태 추적은 계속 돕니다.
            # (그래야 다시 켰을 때 밀린 알림이 한꺼번에 쏟아지지 않습니다)
            if announce(state, title):
                log("{0} -> {1}   [{2}]".format(label, state, title))
                if len(events) > 1:
                    log("   (같은 시점 다른 변화 {0}건)".format(len(events) - 1))
            else:
                log("{0}  (알림 꺼둠 · 건너뜀)   [{1}]".format(label, title))

        time.sleep(POLL)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        log("감시자를 종료합니다.")
326줄
도움말"지금 그 상태인가" 가 아니라 "상태가 방금 바뀌었는가" 로 판단하는 것이 이 감시자의 핵심입니다. 오래 멈춰 있던 작업 때문에 말풍선이 계속 떠 있는 일을 막아줍니다.
13

크롬 확장 폴더 만들기

claude.ai 웹에서도 알림을 받으려면 필요합니다

  1. C:\claude-pet 안에 새 폴더를 만듭니다펫 폴더 "안"에 만드는 것입니다claude-watcher
  2. 아래 세 파일을 그 폴더 안에 저장합니다14 · 15 단계
14

manifest.json 만들기

확장의 명찰입니다

claude-watcher/manifest.json C:\claude-pet\claude-watcher 에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "manifest_version": 3,
  "name": "Claude Pet Watcher",
  "version": "1.0.0",
  "description": "claude.ai 답변 완료를 감지해 데스크탑 펫(포트 5001)에 알립니다.",
  "host_permissions": [
    "http://127.0.0.1:5001/*",
    "http://localhost:5001/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["content.js"],
      "run_at": "document_idle",
      "all_frames": true
    }
  ]
}
21줄
15

background.js · content.js 만들기

감지하고 신호를 보내는 두 파일

claude-watcher/background.js C:\claude-pet\claude-watcher 에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 브리지 서버(포트 5001)로 완료 신호를 보내는 역할만 합니다.
// content.js 가 localhost 로 직접 요청하면 CORS 에 걸릴 수 있어 여기서 대신 보냅니다.

const BRIDGE_URL = "http://127.0.0.1:5001";

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message && message.type === "CLAUDE_DONE") {
    fetch(BRIDGE_URL, {
      method: "POST",
      headers: { "Content-Type": "text/plain" },
      body: "done"
    })
      .then(() => {
        console.log("[ClaudePet] 브리지 서버로 done 전송 완료");
        sendResponse({ ok: true });
      })
      .catch((err) => {
        console.warn("[ClaudePet] 브리지 서버에 연결하지 못했습니다:", err);
        sendResponse({ ok: false, error: String(err) });
      });

    return true; // 비동기 응답을 위해 채널을 열어둡니다
  }
});
24줄
claude-watcher/content.js C:\claude-pet\claude-watcher 에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// claude.ai 에서 "답변 생성이 끝나는 순간"을 감지합니다.
//
// 원리: 답변을 만드는 동안에만 나타나는 [중지] 버튼을 감시합니다.
//       버튼이 있다가 사라지는 순간 = 답변 완료.
//
// ※ claude.ai 화면 구조가 바뀌면 아래 STOP_SELECTORS 만 고치면 됩니다.
//   (F12 개발자도구 > Console 에 [ClaudePet] 로그가 찍힙니다)

(function () {
  "use strict";

  const DEBUG = true;                 // 로그를 끄고 싶으면 false
  const POLL_MS = 400;                // 감시 주기
  const MIN_GENERATING_MS = 1200;     // 이 시간보다 짧으면 오탐으로 보고 무시
  const COOLDOWN_MS = 3000;           // 연속 알림 방지

  const STOP_SELECTORS = [
    '[data-testid="stop-button"]',
    'button[aria-label*="stop" i]',
    'button[aria-label*="중지"]',
    'button[aria-label*="정지"]'
  ];

  let generating = false;
  let startedAt = 0;
  let lastFiredAt = 0;

  function log() {
    if (DEBUG) console.log.apply(console, ["[ClaudePet]"].concat([].slice.call(arguments)));
  }

  function isGenerating() {
    for (const sel of STOP_SELECTORS) {
      const el = document.querySelector(sel);
      if (el && el.offsetParent !== null) return true;
    }
    return false;
  }

  function fireDone() {
    const now = Date.now();
    if (now - lastFiredAt < COOLDOWN_MS) {
      log("쿨다운 중이라 건너뜁니다");
      return;
    }
    lastFiredAt = now;

    log("답변 완료 감지 → 펫에 알림 전송");
    try {
      chrome.runtime.sendMessage({ type: "CLAUDE_DONE" }, function (res) {
        if (chrome.runtime.lastError) {
          log("전송 실패:", chrome.runtime.lastError.message);
        } else {
          log("전송 결과:", res);
        }
      });
    } catch (e) {
      log("전송 중 예외:", e);
    }
  }

  function tick() {
    const now = isGenerating();

    if (now && !generating) {
      generating = true;
      startedAt = Date.now();
      log("답변 생성 시작");
    } else if (!now && generating) {
      generating = false;
      const elapsed = Date.now() - startedAt;
      if (elapsed >= MIN_GENERATING_MS) {
        fireDone();
      } else {
        log("너무 짧아서 무시 (" + elapsed + "ms)");
      }
    }
  }

  setInterval(tick, POLL_MS);
  log("감시를 시작했습니다. (" + location.host + ")");
})();
82줄 · [중지] 버튼이 사라지는 순간을 잡아냅니다
16

크롬에 확장 설치하기

만든 확장을 등록합니다

  1. 크롬 주소창에 입력하고 엔터chrome://extensions
  2. 오른쪽 위 [개발자 모드] 스위치를 켭니다
  3. 왼쪽 위 [압축해제된 확장 프로그램을 로드합니다] 클릭
  4. 아래 폴더를 고르고 [폴더 선택]claude-pet 이 아니라 그 안의 claude-watcher 입니다C:\claude-pet\claude-watcher
도움말"서비스 워커 (비활성)" 이라고 떠 있어도 정상입니다. 신호가 오면 깨어납니다.
17

start_claude_pet.vbs 만들기

검은 창 없이 조용히 켜주는 시작 파일

start_claude_pet.vbs C:\claude-pet 에 저장 (형식은 [모든 파일])
1
2
3
4
5
Set WshShell = CreateObject("WScript.Shell")

' Claude pet only. The pet itself starts the bridge and the watcher,
' and stops them again when you quit from the tray icon.
WshShell.Run "cmd /c cd /d C:\claude-pet && npm start", 0, False
5줄
주의한글이 들어간 .vbs 는 메모장에서 저장할 때 인코딩을 [ANSI] 로 하세요. UTF-8 로 저장하면 실행할 때 한글이 깨집니다.
18

바탕화면 바로가기 만들기

매번 폴더를 열지 않아도 되게

바탕화면_바로가기_만들기.vbs C:\claude-pet 에 저장 (형식 [모든 파일], 인코딩 [ANSI])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Set WshShell = CreateObject("WScript.Shell")

strDesktop = WshShell.SpecialFolders("Desktop")

Set oLink = WshShell.CreateShortcut(strDesktop & "\클로드 펫.lnk")
oLink.TargetPath = "wscript.exe"
oLink.Arguments = """C:\claude-pet\start_claude_pet.vbs"""
oLink.WorkingDirectory = "C:\claude-pet"
oLink.IconLocation = "C:\claude-pet\pet.ico"
oLink.Description = "클로드 작업 알람 펫 실행"
oLink.Save

MsgBox "바탕화면에 '클로드 펫' 바로가기를 만들었습니다." & vbCrLf & vbCrLf & _
       "작업표시줄에 고정하려면 그 바로가기를 마우스로 끌어 작업표시줄에 놓으세요.", _
       64, "클로드 펫"
15줄
도움말저장한 뒤 이 파일을 두 번 누르면 바탕화면에 아이콘이 생깁니다. 한 번만 하면 됩니다.
19

시작 시 자동 실행 등록하기

컴퓨터를 켜면 저절로 뜨게

시작시_자동실행_등록.vbs C:\claude-pet 에 저장 (형식 [모든 파일], 인코딩 [ANSI])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Set WshShell = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")

strStartup = WshShell.SpecialFolders("Startup")
strLnk = strStartup & "\클로드 펫.lnk"

If fso.FileExists(strLnk) Then
    answer = MsgBox("이미 자동 실행이 켜져 있습니다." & vbCrLf & vbCrLf & _
                    "끄시겠습니까?" & vbCrLf & _
                    "[예] 자동 실행 끄기   /   [아니오] 그대로 두기", _
                    vbYesNo + vbQuestion, "클로드 펫 자동 실행")
    If answer = vbYes Then
        fso.DeleteFile strLnk
        MsgBox "자동 실행을 껐습니다.", vbInformation, "클로드 펫"
    End If
Else
    Set oLink = WshShell.CreateShortcut(strLnk)
    oLink.TargetPath = "wscript.exe"
    oLink.Arguments = """C:\claude-pet\start_claude_pet.vbs"""
    oLink.WorkingDirectory = "C:\claude-pet"
    oLink.IconLocation = "C:\claude-pet\pet.ico"
    oLink.Description = "클로드 작업 알람 펫 자동 실행"
    oLink.Save
    MsgBox "자동 실행을 켰습니다." & vbCrLf & vbCrLf & _
           "이제 컴퓨터를 켜고 로그인하면 펫이 저절로 나타납니다." & vbCrLf & _
           "끄고 싶을 때는 이 파일을 다시 더블클릭하거나," & vbCrLf & _
           "시계 옆 펫 아이콘 우클릭 메뉴에서 체크를 해제하세요.", _
           vbInformation, "클로드 펫"
End If
29줄
도움말두 번 누르면 등록과 해제가 번갈아 됩니다. 시계 옆 아이콘 우클릭 메뉴에서도 켜고 끌 수 있습니다.
20

실행하기

이제 켜봅니다

  1. 바탕화면의 [클로드 펫] 아이콘을 두 번 누릅니다몇 초 뒤 유령이 나타납니다
  2. 유령을 마우스로 끌어 원하는 자리에 둡니다
  3. 끄거나 숨길 때는 시계 옆 유령 아이콘을 오른쪽 클릭작업표시줄에는 안 뜨므로 이 아이콘이 유일한 조작 창구입니다
주의시계 옆에 아이콘이 안 보이면 시계 옆 ∧ 화살표를 눌러 숨은 아이콘을 펼치세요. 거기서 끌어내 고정해 두면 편합니다.
21

테스트하기

세 가지만 확인하면 완성입니다

  1. 수동 테스트 — 브라우저 주소창에 입력유령이 폴짝 뛰고 5초 뒤 돌아오면 성공http://localhost:5001/done
  2. 웹 테스트 — claude.ai 에서 시간이 걸릴 질문을 합니다답변이 끝나는 순간 울려야 합니다
  3. 앱 테스트 — 클로드 앱에서 오래 걸리는 일을 시킵니다작업 중 → (질문 시 보라색) → 완료 순으로 바뀌면 완성
도움말셋 다 되면 끝입니다. 하나만 안 되면 아래 "안 될 때" 표를 보세요.

만들다 겪은 실패까지.

같은 함정을 피하시라고 남깁니다.

오탐 사건

감시자를 켜자마자 "질문있습니다!" 만 계속 떴다

원인질문 감지 후보를 넉넉히 넣는다고 "입력 대기 중" 을 같이 넣었는데, 이 글자는 옆 목록의 다른 작업에도 항상 떠 있었다.
교훈후보를 늘리지 말고 조사로 확인된 문구 하나만 쓴다. "질문 중" 하나로 줄이자 오탐이 사라졌다.
전송 실패 사건

고친 파일을 보냈는데 컴퓨터에는 옛 버전이 그대로 있었다

원인전송 결과가 "성공" 으로 나와서 반영된 줄 알았다. 실제로는 이전 내용이 덮어써지지 않았다.
교훈보낸 뒤 그 파일을 다시 읽어와 내용을 대조한다. "성공" 메시지만 믿지 않는다.
움직임 사망 사건

색은 보라색으로 잘 바뀌는데 유령이 꼼짝도 안 했다

원인윈도우의 "애니메이션 효과" 가 꺼져 있으면 prefers-reduced-motion 이 켜지고, index.html 이 그 설정을 존중하도록 짜여 있어 흔들림·튀어오름·커서 깜빡임이 전부 멈춘다.
교훈"코드가 맞는데 안 된다" 면 코드 바깥을 의심한다. 창 자체를 움직이는 방식(main.js 의 shakeWindow)으로 바꿔 OS 설정과 무관하게 만들었다.

안 될 때.

대부분 아래 아홉 가지 중 하나입니다.

유령이 안 뜬다
npm install 을 안 했거나 node_modules 가 없습니다. 폴더에서 검은 창을 열고 다시 실행하세요.
말풍선이 안 바뀐다
중계 서버가 안 켜졌습니다. python C:\claude-pet\claude_bridge.py 를 직접 실행해 오류를 확인하세요.
그림이 안 보인다
파일 이름이 코드와 다릅니다. pet.webp 인지 확인하고, pet.png 면 index.html 안의 경로를 고치세요.
시계 옆 아이콘이 없다
pet.ico 가 없거나 이름이 다릅니다. 아이콘이 없으면 끌 방법이 없으니 반드시 만드세요.
claude.ai 에서만 안 울린다
그 화면에서 F12 → Console 에 [ClaudePet] 글자가 있는지 확인하세요. 없으면 확장이 로드되지 않은 것입니다.
앱에서만 안 울린다
C:\claude-pet\watcher_log.txt 를 여세요. 감시자가 무엇을 봤는지 전부 적혀 있습니다.
색은 바뀌는데 안 움직인다
윈도우 애니메이션 효과가 꺼져 있습니다. main.js 의 shakeWindow 가 들어갔는지 확인하세요.
고쳤는데 그대로다
main.js 를 고쳤으면 반드시 껐다 켜야 합니다. 시계 옆 아이콘 우클릭 → 종료 → 바탕화면 바로가기.
한글이 깨져서 실행된다
.vbs 를 UTF-8 로 저장했습니다. 메모장 → 다른 이름으로 저장 → 인코딩 [ANSI] 로 다시 저장하세요.

백업해 두기.

다른 컴퓨터에서 다시 깔 수 있게.

  1. 클라우드(구글 드라이브 등)에 폴더를 하나 만듭니다예: 내 드라이브\클로드\펫\클로드펫_백업
  2. 코드·그림·설명서만 복사합니다크롬 확장 폴더도 함께main.js index.html *.py *.vbs package.json pet.webp pet.ico claude-watcher\
  3. node_modules 폴더는 빼세요파일이 수만 개라 클라우드 용량을 크게 먹습니다. 새 컴퓨터에서 npm install 로 다시 받으면 됩니다
  4. 복원할 때는 백업 폴더를 C:\claude-pet 로 복사한 뒤그 폴더에서 검은 창(cmd)을 열고 실행. 이어서 pip install uiautomation 도npm install
주의클라우드 폴더에서 펫을 바로 실행하지는 마세요. 컴퓨터를 켤 때 드라이브가 아직 연결되기 전이라 안 뜨고, status.txt 를 1초마다 읽고 쓰느라 계속 업로드가 일어납니다.
보안API 키나 비밀번호를 코드 안에 직접 적지 마세요. 이 사이트의 코드에는 그런 정보가 없습니다.