폴더 만들기
모든 파일이 여기 한 곳에 모입니다
- 내 PC → C: 드라이브를 엽니다
- 빈 곳에 마우스 오른쪽 클릭 → 새로 만들기 → 폴더
- 폴더 이름을 정확히 이렇게 짓습니다대문자·띄어쓰기 없이, 가운데는 하이픈(-)
claude-pet
지금 무슨 일이 일어나는지 한눈에.
신호를 받는 경로는 세 갈래입니다. ① 크롬 확장이 claude.ai 화면을 보다가 답변이 끝나면 알림 ② 파이썬 감시자가 클로드 앱의 모든 작업 목록을 보다가 상태가 바뀌면 알림 ③ 주소창에 주소를 쳐서 직접 알림. 셋 다 status.txt 라는 메모지 한 장에 상태를 적고, 유령은 그 메모지만 1초마다 읽습니다.
윈도우 컴퓨터 한 대면 됩니다.
코드는 [복사] 를 누르고 메모장에 붙여넣으세요.
모든 파일이 여기 한 곳에 모입니다
claude-pet유령 창을 띄우는 엔진입니다
https://nodejs.org화면을 읽는 감시자를 돌립니다
https://www.python.org/downloads/Add Python to PATH유령이 될 그림 한 장
pet.webppet.ico이 폴더가 어떤 프로그램인지 알려주는 명찰
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"
}
}
유령 창을 띄워주는 부품을 내려받습니다
cmdnpm install창 모양, 시계 옆 아이콘, 창 흔들기를 담당합니다
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', () => {
// 창이 닫혀도 트레이에 남아 있으므로 바로 끝내지 않습니다
});
유령의 생김새와 말풍선 4종을 그립니다
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>
지금 상태를 적어두는 메모지 한 장
waitingstatus.txt크롬 확장이 보낸 신호를 받는 중계 서버
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)
앱 화면의 글자를 읽는 부품입니다
pip install uiautomation클로드 앱의 모든 작업을 지켜보는 감시자
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("감시자를 종료합니다.")
claude.ai 웹에서도 알림을 받으려면 필요합니다
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
}
]
}
감지하고 신호를 보내는 두 파일
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; // 비동기 응답을 위해 채널을 열어둡니다
}
});
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 + ")");
})();
만든 확장을 등록합니다
chrome://extensionsC:\claude-pet\claude-watcher검은 창 없이 조용히 켜주는 시작 파일
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
매번 폴더를 열지 않아도 되게
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, "클로드 펫"
컴퓨터를 켜면 저절로 뜨게
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
이제 켜봅니다
세 가지만 확인하면 완성입니다
http://localhost:5001/done같은 함정을 피하시라고 남깁니다.
대부분 아래 아홉 가지 중 하나입니다.
다른 컴퓨터에서 다시 깔 수 있게.
main.js index.html *.py *.vbs package.json pet.webp pet.ico claude-watcher\npm install