初始提交:Codex Pets 相容的 Claude Code 桌面寵物

摘要:
Electron 桌面寵物,透過 Claude Code hooks 即時反映工作狀態(思考、等待授權、失敗、完成),並支援 16 方向追視游標。

根本原因:
專案尚未納入版控,需要建立初始 repo 以便後續追蹤與協作。

影響:
建立 main 分支的第一個版本,包含完整可執行的程式碼與內附寵物「小念」。

修法:
納入以下內容:
- main.js / preload.js:Electron 主程序、透明置頂視窗、系統匣選單、HTTP 事件伺服器、游標輪詢與拖曳
- renderer/:Codex V2 spritesheet 幀動畫播放器、session 狀態機、氣泡、alpha 點穿判定
- hook/claude-pet-hook.js:Claude Code hook 端,stdin → POST /event,永遠 exit 0
- lib/、scripts/:hooks 安裝/移除、Windows 開機自動啟動
- pets/xiao-nian:內附寵物包(pet.json + spritesheet.webp)
- README.md、docs/states.png:使用說明與狀態總覽圖

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 10:32:39 +08:00
co-authored by Claude Fable 5
commit b1d57249f8
18 changed files with 1889 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.log
+303
View File
@@ -0,0 +1,303 @@
# Claude Pet
**Codex Pets 相容的 Claude Code 桌面寵物。**
把 Codex `/hatch` 產出的寵物包原封不動拿來用,讓它站在桌面上,即時反映 Claude Code 正在做什麼:思考、等你授權、失敗、完成……還會盯著你的滑鼠游標看。
![狀態總覽](docs/states.png)
> 上圖由左至右:SessionStart 揮手、工作中、等待授權、完成時跳躍、檢視成果。氣泡會標出是哪個專案的 session。
---
## 目錄
- [特色](#特色)
- [需求](#需求)
- [快速開始](#快速開始)
- [日常使用](#日常使用)
- [行為對照表](#行為對照表)
- [寵物包格式(Codex V2](#寵物包格式codex-v2)
- [新增 / 切換寵物](#新增--切換寵物)
- [設定檔](#設定檔)
- [架構與檔案](#架構與檔案)
- [HTTP API](#http-api)
- [疑難排解](#疑難排解)
- [移除](#移除)
---
## 特色
- **完整實作 Codex V2 pet contract** — 8 欄 × 11 列、192×208 cell 的 spritesheet9 個標準動作列 + 16 個看向方向,**每一幀的時長都照 Codex 規格**,動起來跟在 Codex app 裡一模一樣。
- **16 方向追視** — 游標在哪,她就看哪(000 = 正上方、順時針 22.5° 一格)。游標靠太近或靜止 6 秒就回到 idle 呼吸動畫。
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。
- **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。
- **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。
- **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼。
- **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。
- **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`Codex hatch 出來的寵物直接在選單裡切換。
- **Hook 零負擔** — hook 腳本約 100 ms 完成、寵物沒開也靜默 exit 0,不會拖慢或卡住 Claude Code。
## 需求
| 項目 | 說明 |
|---|---|
| 作業系統 | Windows 10 / 11(透明視窗、點穿、系統匣、開機啟動皆以 Windows 實作) |
| Node.js | 18 以上(開發時為 24.19 |
| Claude Code | 2.1 以上(使用 `SessionStart``PermissionRequest``PostToolUseFailure``StopFailure``PreCompact` 等事件) |
| 寵物包 | 一個 Codex V2 寵物包(`pet.json` + `spritesheet.webp`)。本專案內附 `pets/xiao-nian`(小念) |
## 快速開始
```powershell
cd E:\Project\Test\AI\Claude\claude-pet
npm install
npm run hooks:install # 把 13 個 hook 合併進 ~/.claude/settings.json(會先備份到 ~/.claude/backups
npm start # 啟動寵物
npm run autostart:install # (可選)開機自動啟動
```
> **注意**
> 1. 已開啟的 Claude Code session 不會重新讀 hooks**重開一個新 session** 才會看到她反應。
> 2. 若 `npm install` 後 `node_modules\electron\dist\` 是空的,請看 [疑難排解](#electron-裝完沒有-electronexe)。
## 日常使用
| 操作 | 效果 |
|---|---|
| 左鍵點一下 | 揮手(若有非固定的氣泡,先關閉氣泡) |
| 左鍵拖曳 | 搬家;往左播 running-left、往右播 running-right;放開後位置寫入設定檔 |
| 右鍵 / 系統匣左鍵 | 開啟選單 |
| 滑鼠靠近 | 盯著游標看(可在選單關閉) |
**選單功能**
- **切換寵物** — 列出所有找到的寵物;v1(9 列)寵物會標示「無追視」
- **大小** — 50% / 75% / 100% / 125% / 150%,以腳底為基準縮放
- **跟著游標看**、**永遠置頂**、**開機自動啟動** — 勾選即生效
- **動作測試** — 不用真的跑 Claude Code 也能看每個動畫(idle / running / waiting / review / failed / jumping / waving
- **Claude Code hooks** — 安裝 / 移除 hooks
- **回到預設位置** — 跑到螢幕外面時用
- **開啟設定檔** / **開啟事件紀錄**
- **結束**
## 行為對照表
| Claude Code hook | 條件 | 動作 | 氣泡 |
|---|---|---|---|
| `SessionStart` | `source = startup` | `waving` 一次 → `idle` | 👋 嗨!(專案) |
| `UserPromptSubmit` | | `running` | (關閉舊氣泡) |
| `PreToolUse` | 一般工具 | `running` | |
| `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 |
| `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 |
| `PostToolUse` | | `running` | |
| `PostToolUseFailure` | | `failed` 一次 → `running` | 😵 〈工具〉失敗了 |
| `PermissionRequest` | | `waiting` | 🔐 需要你授權:〈工具〉 |
| `Notification` | `permission_prompt` | `waiting` | 🔐 需要你授權 |
| `Notification` | `idle_prompt` | `waiting` | 💤 在等你回覆 |
| `Notification` | `elicitation_dialog` | `waiting` | ❓ 有問題想問你 |
| `Notification` | 其他 | (不變) | 💬 訊息內容 |
| `Stop` | `stop_hook_active = false` | `jumping` 一次 → `review` 6 秒 → `idle` | ✅ 完成!(專案) |
| `StopFailure` | | `failed` 兩次 → `idle` | ❌ 錯誤訊息 |
| `SubagentStart` / `SubagentStop` | | `running` | |
| `PreCompact` | | `running` | 🗜️ 整理記憶中… |
| `SessionEnd` | | 移除該 session | 最後一個結束時 👋 掰掰 |
| (無事件) | 游標移動 | 16 方向 `look` | |
| (無事件) | 拖曳 | `running-left` / `running-right` | |
規則:
- **一次性動作**waving / jumping / failed)播完自動回到該 session 的基礎狀態。
- **基礎狀態**依 session 彙整:`waiting(3) > running(2) > review(1) > idle(0)`,取最高者顯示。
- 30 分鐘沒有任何事件的 session 會被視為結束而忽略(避免沒送 `SessionEnd` 的終端機卡住狀態)。
- `waiting` 的氣泡是固定的,狀態離開 `waiting` 時自動消失。
## 寵物包格式(Codex V2
與 Codex 完全相同,可直接互通。一個寵物是一個資料夾:
```
<pet-id>/
├── pet.json
└── spritesheet.webp (或 .png
```
`pet.json`
```json
{
"id": "xiao-nian",
"displayName": "小念",
"description": "一句話描述",
"spriteVersionNumber": 2,
"spritesheetPath": "spritesheet.webp"
}
```
Spritesheet**1536 × 2288**8 欄 × 11 列,每格 **192 × 208**,透明背景。
| 列 | 狀態 | 使用欄 | 每幀時長(ms) |
|---|---|---|---|
| 0 | idle | 05 | 280, 110, 110, 140, 140, 320 |
| 1 | running-right | 07 | 120 ×7, 220 |
| 2 | running-left | 07 | 120 ×7, 220 |
| 3 | waving | 03 | 140 ×3, 280 |
| 4 | jumping | 04 | 140 ×4, 280 |
| 5 | failed | 07 | 140 ×7, 240 |
| 6 | waiting | 05 | 150 ×5, 260 |
| 7 | running(工作中,不是跑步) | 05 | 120 ×5, 220 |
| 8 | review | 05 | 150 ×5, 280 |
| 9 | look A | 07 | 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5 度 |
| 10 | look B | 07 | 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5 度 |
- look 列的 **000 是正上方(12 點鐘)**,順時針。正面/中立不在 16 格裡,那是游標死區,退回 idle。
- 列 0 第 6 欄若有圖會被當作「中立正面」,拿來做系統匣圖示。
- 沒有 `spriteVersionNumber: 2` 的舊版(8×9、1536×1872)寵物也能用,只是沒有追視。
> 規格來源:Codex 的 `hatch-pet` skill`~/.codex/skills/hatch-pet/references/codex-pet-contract.md`、`animation-rows.md`)。
## 新增 / 切換寵物
1. **用 Codex hatch** — 在 Codex 裡 `/hatch`,完成後寵物會在 `%USERPROFILE%\.codex\pets\<id>\`,本程式預設掃描該資料夾,選單「切換寵物 → 重新掃描」就會出現。
2. **手動放入** — 把寵物資料夾複製到 `pets\<id>\`(選單「開啟寵物資料夾」可直接打開)。
3. **其他位置** — 在設定檔 `petSources` 加路徑。
切換後立即生效,不用重啟。
## 設定檔
位置:`%USERPROFILE%\.claude-pet\config.json`(第一次啟動後自動產生,選單「開啟設定檔」可直接開)。
| 欄位 | 預設 | 說明 |
|---|---|---|
| `activePetId` | `"xiao-nian"` | 目前使用的寵物 id |
| `petSources` | `[<app>/pets, ~/.codex/pets]` | 掃描寵物的資料夾清單;程式自己的 `pets/` 永遠會被加在最前面 |
| `scale` | `1` | 縮放:0.5 / 0.75 / 1 / 1.25 / 1.5 |
| `position` | `null` | 視窗左上角座標;`null` 為主螢幕右下角。若座標落在所有螢幕之外會自動拉回 |
| `alwaysOnTop` | `true` | 永遠置頂 |
| `followCursor` | `true` | 追視游標 |
| `port` | `17333` | 接收 hook 事件的本機埠(改了之後 hook 端要設環境變數 `CLAUDE_PET_PORT` |
事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。
## 架構與檔案
```
Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──POST /event──▶ 127.0.0.1:17333
│ Electron mainmain.js
│ 視窗 / 系統匣 / 游標輪詢 / 拖曳 / 設定
▼ IPC
renderer/renderer.js
狀態機 + 幀動畫 + 追視 + 氣泡 + alpha 點穿判定
```
| 檔案 | 職責 |
|---|---|
| `main.js` | Electron 主程序:透明置頂視窗、系統匣選單、HTTP 伺服器、游標輪詢(50 ms)、拖曳(60 fps)、設定讀寫、寵物掃描 |
| `preload.js` | 以 contextBridge 暴露白名單 IPC 頻道給 renderersandbox 模式) |
| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠 alpha 判定 |
| `renderer/index.html``style.css` | 版面與氣泡樣式(CSP 僅允許 self / data: |
| `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST800 ms 逾時、永遠 exit 0 |
| `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) |
| `lib/autostart.js` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet` 登錄值 |
| `scripts/*.js` | 上述兩者的 CLI 包裝 |
| `pets/` | 內附寵物 |
| `docs/` | README 用圖 |
設計重點:
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`renderer 在 mousemove 時讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。
- **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。
- **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。
- **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。
## HTTP API
只監聽 `127.0.0.1`
| 方法 | 路徑 | 說明 |
|---|---|---|
| `POST` | `/event` | 接收事件(JSON,≤ 64 KB),回 `204` |
| `GET` | `/state` | 目前狀態:`{"anim","base","sessions","pet","scale"}` |
| `GET` | `/health` | 回 `ok` |
`POST /event` 的 payloadhook 腳本送出的格式):
```json
{
"event": "Stop",
"sessionId": "uuid",
"cwd": "E:\\Project\\foo",
"toolName": null,
"notificationType": null,
"message": null,
"error": null,
"source": null,
"stopHookActive": false,
"ts": 1787278568523
}
```
手動觸發範例:
```powershell
# 模擬需要授權
curl -X POST http://127.0.0.1:17333/event -H "content-type: application/json" `
-d '{"event":"Notification","sessionId":"demo","cwd":"D:/demo","notificationType":"permission_prompt"}'
# 模擬完成
curl -X POST http://127.0.0.1:17333/event -H "content-type: application/json" `
-d '{"event":"Stop","sessionId":"demo","cwd":"D:/demo"}'
# 查狀態
curl http://127.0.0.1:17333/state
```
也可以送 `{"event":"__test","anim":"waiting"}``idle|running|waiting|review|failed|jumping|waving|clear`)直接指定動畫,與選單「動作測試」相同。
## 疑難排解
### Electron 裝完沒有 `electron.exe`
`npm install` 成功但 `node_modules\electron\dist\` 是空的 → 你的 `~/.npmrc` 可能有 `allow-scripts=...`(npm 11 的供應鏈防護,只放行清單上套件的 postinstall)。兩種解法:
```powershell
node node_modules\electron\install.js # 手動跑一次下載
# 或把 electron 加進白名單
npm config set allow-scripts=@anthropic-ai/claude-code,electron --location=user
```
### 寵物對 Claude Code 沒反應
1. 確認 hooks 已安裝:`npm run hooks:install`,並**重開 Claude Code session**。
2. 確認寵物在跑:`curl http://127.0.0.1:17333/health` 應回 `ok`
3.`%USERPROFILE%\.claude-pet\events.log` 有沒有事件進來。有事件但沒動 → renderer 問題;沒事件 → hook 沒被呼叫。
4. hook 指令是 `node E:/.../claude-pet-hook.js`Claude Code 執行 hook 的環境要找得到 `node`PATH)。
### 啟動時跳出「無法監聽 127.0.0.1:17333」
已經有一隻在跑(本程式有單一實例鎖,通常不會發生),或埠被別的程式佔用。改設定檔 `port`,並在 Claude Code 的環境設 `CLAUDE_PET_PORT` 為同一個值。
### 她跑到螢幕外 / 換了螢幕配置看不到
選單「回到預設位置」,或刪掉設定檔的 `position`。啟動時若座標不在任何螢幕的工作區內會自動拉回。
### 專案搬家了
重新執行 `npm run hooks:install``npm run autostart:install`(兩者都以目前路徑重寫),寵物資料夾會自動跟著程式走。
## 移除
```powershell
npm run hooks:uninstall # 從 ~/.claude/settings.json 移除所有 Claude Pet hook(先備份)
npm run autostart:uninstall # 移除開機啟動登錄值
```
然後刪掉專案資料夾與 `%USERPROFILE%\.claude-pet\` 即可。
---
## 致謝
- 寵物規格與動畫時序來自 OpenAI Codex 的 `hatch-pet` skillCodex V2 pet contract)。
- 內附寵物「小念」由 Codex `/hatch` 產生。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
"use strict";
// Claude Code hook → Claude Pet
// 讀 stdin 的 hook JSONPOST 到本機的寵物 app。任何失敗都靜默結束(exit 0),絕不阻塞 Claude Code。
const http = require("node:http");
const PORT = Number(process.env.CLAUDE_PET_PORT || 17333);
const TIMEOUT_MS = 800;
function trim(value, max) {
if (typeof value !== "string") return undefined;
return value.length > max ? `${value.slice(0, max)}` : value;
}
function readStdin() {
return new Promise((resolve) => {
if (process.stdin.isTTY) return resolve({});
let data = "";
const timer = setTimeout(() => resolve(parse(data)), TIMEOUT_MS);
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { data += chunk; });
process.stdin.on("end", () => { clearTimeout(timer); resolve(parse(data)); });
process.stdin.on("error", () => { clearTimeout(timer); resolve(parse(data)); });
});
}
function parse(text) {
try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
}
function buildPayload(input) {
const error = typeof input.error === "string" ? input.error : input.error?.message;
return {
event: input.hook_event_name || process.argv[2] || "Unknown",
sessionId: input.session_id ?? null,
cwd: input.cwd ?? null,
permissionMode: input.permission_mode ?? null,
toolName: input.tool_name ?? null,
notificationType: input.notification_type ?? null,
title: trim(input.title, 160),
message: trim(input.message, 240),
error: trim(error, 240),
source: input.source ?? null,
reason: input.reason ?? null,
trigger: input.trigger ?? null,
stopHookActive: input.stop_hook_active ?? null,
lastAssistantMessage: trim(input.last_assistant_message, 300),
prompt: trim(input.prompt, 120),
ts: Date.now(),
};
}
function post(payload) {
return new Promise((resolve) => {
const body = Buffer.from(JSON.stringify(payload), "utf8");
const req = http.request(
{
host: "127.0.0.1",
port: PORT,
method: "POST",
path: "/event",
headers: { "content-type": "application/json", "content-length": body.length },
},
(res) => { res.resume(); res.on("end", resolve); res.on("error", resolve); },
);
req.setTimeout(TIMEOUT_MS, () => req.destroy());
req.on("error", resolve);
req.end(body);
});
}
(async () => {
const guard = setTimeout(() => process.exit(0), TIMEOUT_MS * 2 + 500);
guard.unref();
try {
const input = await readStdin();
await post(buildPayload(input));
} catch {
// ignore
}
process.exit(0);
})();
+50
View File
@@ -0,0 +1,50 @@
"use strict";
// Windows 開機自動啟動:HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet
const path = require("node:path");
const fs = require("node:fs");
const { spawnSync } = require("node:child_process");
const APP_DIR = path.resolve(__dirname, "..");
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
const VALUE_NAME = "ClaudePet";
function electronExe() {
return path.join(APP_DIR, "node_modules", "electron", "dist", "electron.exe");
}
function command() {
return `"${electronExe()}" "${APP_DIR}"`;
}
function reg(args) {
const r = spawnSync("reg", args, { encoding: "utf8", windowsHide: true });
return { ok: r.status === 0, stdout: r.stdout || "", stderr: r.stderr || "" };
}
function isInstalled() {
if (process.platform !== "win32") return false;
return reg(["query", RUN_KEY, "/v", VALUE_NAME]).ok;
}
function currentValue() {
const r = reg(["query", RUN_KEY, "/v", VALUE_NAME]);
if (!r.ok) return null;
const m = r.stdout.match(/REG_SZ\s+(.+)$/m);
return m ? m[1].trim() : null;
}
function install() {
if (process.platform !== "win32") throw new Error("只支援 Windows");
if (!fs.existsSync(electronExe())) throw new Error(`找不到 Electron${electronExe()}(先執行 npm install`);
const r = reg(["add", RUN_KEY, "/v", VALUE_NAME, "/t", "REG_SZ", "/d", command(), "/f"]);
if (!r.ok) throw new Error(r.stderr || r.stdout || "reg add 失敗");
return command();
}
function uninstall() {
if (process.platform !== "win32") return false;
const r = reg(["delete", RUN_KEY, "/v", VALUE_NAME, "/f"]);
return r.ok;
}
module.exports = { install, uninstall, isInstalled, currentValue, command, electronExe, VALUE_NAME, RUN_KEY };
+111
View File
@@ -0,0 +1,111 @@
"use strict";
// 把 Claude Pet 的 hook 合併進 ~/.claude/settings.json(保留使用者原本的設定與其他 hooks)
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const APP_DIR = path.resolve(__dirname, "..");
const CLAUDE_DIR = path.join(os.homedir(), ".claude");
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
const BACKUP_DIR = path.join(CLAUDE_DIR, "backups");
const HOOK_SCRIPT = path.join(APP_DIR, "hook", "claude-pet-hook.js");
const MARKER = "claude-pet-hook";
// Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態)
const EVENTS = [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"Notification",
"Stop",
"StopFailure",
"SubagentStart",
"SubagentStop",
"PreCompact",
];
function hookCommand() {
const p = HOOK_SCRIPT.replace(/\\/g, "/");
return /\s/.test(p) ? `node "${p}"` : `node ${p}`;
}
function readSettings() {
try {
const text = fs.readFileSync(SETTINGS_PATH, "utf8");
return text.trim() ? JSON.parse(text) : {};
} catch (err) {
if (err.code === "ENOENT") return {};
throw new Error(`無法解析 ${SETTINGS_PATH}${err.message}`);
}
}
function writeSettings(settings) {
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
const tmp = `${SETTINGS_PATH}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
fs.renameSync(tmp, SETTINGS_PATH);
}
function backup() {
if (!fs.existsSync(SETTINGS_PATH)) return null;
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(BACKUP_DIR, `settings.claude-pet.${stamp}.json`);
fs.copyFileSync(SETTINGS_PATH, dest);
return dest;
}
function isPetHook(entry) {
try { return JSON.stringify(entry).includes(MARKER); } catch { return false; }
}
function normalize(list) {
if (Array.isArray(list)) return list;
if (list && typeof list === "object") return [list];
return [];
}
function install() {
const settings = readSettings();
const backupPath = backup();
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
for (const event of EVENTS) {
const kept = normalize(settings.hooks[event]).filter((e) => !isPetHook(e));
kept.push({ hooks: [{ type: "command", command: hookCommand(), timeout: 5 }] });
settings.hooks[event] = kept;
}
writeSettings(settings);
return { events: EVENTS, settingsPath: SETTINGS_PATH, backupPath, command: hookCommand() };
}
function uninstall() {
const settings = readSettings();
if (!settings.hooks || typeof settings.hooks !== "object") return { removed: 0 };
const backupPath = backup();
let removed = 0;
for (const event of Object.keys(settings.hooks)) {
const before = normalize(settings.hooks[event]);
const after = before.filter((e) => !isPetHook(e));
removed += before.length - after.length;
if (after.length) settings.hooks[event] = after;
else delete settings.hooks[event];
}
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
writeSettings(settings);
return { removed, settingsPath: SETTINGS_PATH, backupPath };
}
function isInstalled() {
try {
const settings = readSettings();
return normalize(settings.hooks?.Stop).some(isPetHook);
} catch {
return false;
}
}
module.exports = { install, uninstall, isInstalled, hookCommand, EVENTS, SETTINGS_PATH };
+500
View File
@@ -0,0 +1,500 @@
"use strict";
const { app, BrowserWindow, Tray, Menu, screen, ipcMain, nativeImage, shell, dialog } = require("electron");
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const autostart = require("./lib/autostart");
const hooksInstaller = require("./lib/hooks-installer");
const APP_DIR = __dirname;
const HOME = os.homedir();
const CONFIG_DIR = path.join(HOME, ".claude-pet");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
const LOG_PATH = path.join(CONFIG_DIR, "events.log");
const CELL_W = 192;
const CELL_H = 208;
const BUBBLE_H = 72; // 氣泡保留高度
const PAD_X = 24; // 左右透明邊
const PAD_BOTTOM = 8;
const SCALES = [0.5, 0.75, 1, 1.25, 1.5];
const DEFAULT_CONFIG = {
activePetId: "xiao-nian",
petSources: [path.join(APP_DIR, "pets"), path.join(HOME, ".codex", "pets")],
scale: 1,
position: null,
alwaysOnTop: true,
followCursor: true,
port: 17333,
};
let config = loadConfig();
let win = null;
let tray = null;
let pets = [];
let dragTimer = null;
let cursorTimer = null;
let lastCursor = { x: NaN, y: NaN };
let currentState = { anim: "idle", base: "idle", sessions: 0 };
// ---------- config ----------
function loadConfig() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { /* first run */ }
const merged = { ...DEFAULT_CONFIG, ...saved };
if (!Array.isArray(merged.petSources)) merged.petSources = [];
// 永遠包含 app 自己的 pets 資料夾(就算專案搬家了也找得到)
const ownPets = path.join(APP_DIR, "pets");
merged.petSources = [ownPets, ...merged.petSources.filter((p) => typeof p === "string" && path.resolve(p) !== ownPets && fs.existsSync(p))];
if (!merged.petSources.includes(DEFAULT_CONFIG.petSources[1]) && fs.existsSync(DEFAULT_CONFIG.petSources[1])) merged.petSources.push(DEFAULT_CONFIG.petSources[1]);
if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
merged.scale = Number(merged.scale);
return merged;
}
function saveConfig() {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
} catch (err) {
console.error("saveConfig failed:", err);
}
}
function appendLog(line) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
try { if (fs.statSync(LOG_PATH).size > 2 * 1024 * 1024) fs.truncateSync(LOG_PATH, 0); } catch { /* no file yet */ }
fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`, "utf8");
} catch { /* ignore */ }
}
// ---------- pets ----------
function discoverPets() {
const found = [];
const seen = new Set();
for (const src of config.petSources) {
let entries = [];
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; }
for (const ent of entries) {
if (!ent.isDirectory()) continue;
const dir = path.join(src, ent.name);
let manifest;
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "pet.json"), "utf8")); } catch { continue; }
const id = String(manifest.id || ent.name);
if (seen.has(id)) continue;
const spritesheetPath = path.join(dir, manifest.spritesheetPath || "spritesheet.webp");
if (!fs.existsSync(spritesheetPath)) continue;
seen.add(id);
found.push({
id,
displayName: manifest.displayName || id,
description: manifest.description || "",
dir,
spritesheetPath,
version: Number(manifest.spriteVersionNumber) === 2 ? 2 : 1,
});
}
}
return found;
}
function activePet() {
return pets.find((p) => p.id === config.activePetId) || pets[0] || null;
}
function petPayload(pet) {
if (!pet) return null;
const ext = path.extname(pet.spritesheetPath).toLowerCase();
const mime = ext === ".png" ? "image/png" : "image/webp";
const data = fs.readFileSync(pet.spritesheetPath).toString("base64");
return {
id: pet.id,
displayName: pet.displayName,
description: pet.description,
version: pet.version,
dataUrl: `data:${mime};base64,${data}`,
};
}
// ---------- window ----------
function windowSizeFor(scale) {
return {
width: Math.round(CELL_W * scale) + PAD_X * 2,
height: Math.round(CELL_H * scale) + BUBBLE_H + PAD_BOTTOM,
};
}
function defaultPosition(size) {
const { workArea } = screen.getPrimaryDisplay();
return {
x: workArea.x + workArea.width - size.width - 24,
y: workArea.y + workArea.height - size.height - 4,
};
}
function clampToDisplays(pos, size) {
const display = screen.getDisplayMatching({ x: pos.x, y: pos.y, width: size.width, height: size.height });
const wa = display.workArea;
return {
x: Math.round(Math.min(Math.max(pos.x, wa.x - size.width + 48), wa.x + wa.width - 48)),
y: Math.round(Math.min(Math.max(pos.y, wa.y - 16), wa.y + wa.height - 48)),
};
}
function createWindow() {
const size = windowSizeFor(config.scale);
const pos = clampToDisplays(config.position || defaultPosition(size), size);
win = new BrowserWindow({
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
transparent: true,
frame: false,
alwaysOnTop: config.alwaysOnTop,
skipTaskbar: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
hasShadow: false,
focusable: false,
show: false,
title: "Claude Pet",
webPreferences: {
preload: path.join(APP_DIR, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
},
});
win.setMenu(null);
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
win.setIgnoreMouseEvents(true, { forward: true });
win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
win.once("ready-to-show", () => win.showInactive());
win.on("closed", () => { win = null; });
}
function send(channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data);
}
function sendInit() {
send("pet:init", {
pet: petPayload(activePet()),
scale: config.scale,
followCursor: config.followCursor,
});
}
// ---------- drag ----------
function startDrag(offsetX, offsetY) {
if (!win) return;
endDrag(false);
let last = screen.getCursorScreenPoint();
let lastDx = 0;
const startedAt = Date.now();
dragTimer = setInterval(() => {
if (!win || Date.now() - startedAt > 60000) return endDrag(true);
const p = screen.getCursorScreenPoint();
const dx = p.x - last.x;
last = p;
if (Math.abs(dx) >= 1) lastDx = dx;
win.setPosition(Math.round(p.x - offsetX), Math.round(p.y - offsetY), false);
send("pet:drag-move", { dx: lastDx });
}, 16);
}
function endDrag(save) {
if (dragTimer) {
clearInterval(dragTimer);
dragTimer = null;
}
if (save && win) {
const [x, y] = win.getPosition();
config.position = { x, y };
saveConfig();
}
}
// ---------- cursor look ----------
function startCursorPolling() {
cursorTimer = setInterval(() => {
if (!win || !config.followCursor || dragTimer) return;
const p = screen.getCursorScreenPoint();
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
lastCursor = p;
const b = win.getBounds();
const cx = b.x + b.width / 2;
const cy = b.y + BUBBLE_H + CELL_H * config.scale * 0.3; // 大約頭部的高度
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
}, 50);
}
// ---------- HTTP:接收 Claude Code hook ----------
function startServer() {
const server = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/event") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on("end", () => {
let payload = null;
try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
if (payload && typeof payload === "object") {
appendLog(JSON.stringify(payload));
send("pet:event", payload);
res.writeHead(204);
} else {
res.writeHead(400);
}
res.end();
});
return;
}
if (req.method === "GET" && req.url === "/state") {
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ...currentState, pet: activePet()?.id || null, scale: config.scale }));
return;
}
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
return;
}
res.writeHead(404);
res.end();
});
server.on("error", (err) => {
dialog.showErrorBox("Claude Pet", `無法監聽 127.0.0.1:${config.port}\n${err.message}\n\n是不是已經有一隻在跑了?`);
});
server.listen(config.port, "127.0.0.1");
}
// ---------- tray / menu ----------
function fallbackTrayIcon() {
const size = 16;
const buf = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - 7.5;
const dy = y - 7.5;
const inside = dx * dx + dy * dy <= 6.5 * 6.5;
const i = (y * size + x) * 4;
buf[i] = 0x4a; buf[i + 1] = 0x7c; buf[i + 2] = 0xff; buf[i + 3] = inside ? 255 : 0; // BGRA
}
}
return nativeImage.createFromBitmap(buf, { width: size, height: size });
}
function createTray() {
tray = new Tray(fallbackTrayIcon());
tray.setToolTip("Claude Pet");
tray.on("click", () => tray.popUpContextMenu(buildMenu()));
refreshTray();
}
function refreshTray() {
if (!tray) return;
tray.setContextMenu(buildMenu());
const pet = activePet();
tray.setToolTip(`Claude Pet ${pet ? pet.displayName : "無寵物"}\n狀態:${currentState.anim}${currentState.sessions} 個 session`);
}
function runSafely(fn) {
try { fn(); } catch (err) { dialog.showErrorBox("Claude Pet", String(err.message || err)); }
refreshTray();
}
function info(message) {
dialog.showMessageBox({ type: "info", title: "Claude Pet", message, buttons: ["OK"] });
}
function buildMenu() {
const pet = activePet();
const TESTS = [
["idle", "待機"], ["running", "工作中"], ["waiting", "等待授權"], ["review", "檢視成果"],
["failed", "失敗"], ["jumping", "跳躍(完成)"], ["waving", "揮手"],
];
return Menu.buildFromTemplate([
{ label: pet ? `${pet.displayName}${pet.id}` : "沒有可用的寵物", enabled: false },
{ type: "separator" },
{
label: "切換寵物",
submenu: [
...pets.map((p) => ({
label: `${p.displayName}${p.version === 2 ? "" : "v1,無追視)"}`,
type: "radio",
checked: p.id === pet?.id,
click: () => switchPet(p.id),
})),
{ type: "separator" },
{ label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } },
{ label: "開啟寵物資料夾", click: () => shell.openPath(path.join(APP_DIR, "pets")) },
],
},
{
label: "大小",
submenu: SCALES.map((s) => ({
label: `${Math.round(s * 100)}%`,
type: "radio",
checked: s === config.scale,
click: () => setScale(s),
})),
},
{
label: "跟著游標看",
type: "checkbox",
checked: config.followCursor,
click: (item) => {
config.followCursor = item.checked;
saveConfig();
send("pet:settings", { followCursor: config.followCursor });
},
},
{
label: "永遠置頂",
type: "checkbox",
checked: config.alwaysOnTop,
click: (item) => {
config.alwaysOnTop = item.checked;
saveConfig();
win?.setAlwaysOnTop(item.checked, "screen-saver");
},
},
{
label: "開機自動啟動",
type: "checkbox",
checked: autostart.isInstalled(),
click: (item) => runSafely(() => (item.checked ? autostart.install() : autostart.uninstall())),
},
{ type: "separator" },
{
label: "動作測試",
submenu: [
...TESTS.map(([anim, label]) => ({ label, click: () => send("pet:event", { event: "__test", anim }) })),
{ type: "separator" },
{ label: "清除測試狀態", click: () => send("pet:event", { event: "__test", anim: "clear" }) },
],
},
{
label: "Claude Code hooks",
submenu: [
{
label: hooksInstaller.isInstalled() ? "已安裝 ✓(重新安裝)" : "安裝到 ~/.claude/settings.json",
click: () => runSafely(() => {
const r = hooksInstaller.install();
info(`已安裝 ${r.events.length} 個 hook 事件。\n\n已開啟的 Claude Code session 要重新啟動才會生效。`);
}),
},
{
label: "移除",
enabled: hooksInstaller.isInstalled(),
click: () => runSafely(() => {
const r = hooksInstaller.uninstall();
info(`已移除 ${r.removed} 個 hook。`);
}),
},
],
},
{ label: "回到預設位置", click: resetPosition },
{ label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } },
{ label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } },
{ type: "separator" },
{ label: "結束", click: () => app.quit() },
]);
}
function switchPet(id) {
if (!pets.some((p) => p.id === id)) return;
config.activePetId = id;
saveConfig();
send("pet:load", { pet: petPayload(activePet()) });
refreshTray();
}
function setScale(scale) {
if (!win) return;
const b = win.getBounds();
const size = windowSizeFor(scale);
const x = Math.round(b.x + (b.width - size.width) / 2);
const y = Math.round(b.y + (b.height - size.height));
win.setBounds({ x, y, width: size.width, height: size.height }, false);
config.scale = scale;
config.position = { x, y };
saveConfig();
send("pet:scale", { scale });
refreshTray();
}
function resetPosition() {
if (!win) return;
const pos = defaultPosition(windowSizeFor(config.scale));
win.setPosition(pos.x, pos.y, false);
config.position = null;
saveConfig();
}
// ---------- IPC ----------
ipcMain.on("pet:ready", () => sendInit());
ipcMain.on("pet:ignore-mouse", (_e, ignore) => win?.setIgnoreMouseEvents(!!ignore, { forward: true }));
ipcMain.on("pet:drag-start", (_e, { offsetX, offsetY }) => startDrag(Number(offsetX) || 0, Number(offsetY) || 0));
ipcMain.on("pet:drag-end", () => endDrag(true));
ipcMain.on("pet:context-menu", () => buildMenu().popup({ window: win }));
ipcMain.on("pet:tray-icon", (_e, dataUrl) => {
try { tray?.setImage(nativeImage.createFromDataURL(dataUrl)); } catch (err) { console.error(err); }
});
ipcMain.on("pet:state", (_e, state) => {
currentState = { ...currentState, ...state };
refreshTray();
});
ipcMain.on("pet:log", (_e, line) => appendLog(String(line)));
// ---------- app ----------
app.setAppUserModelId("com.jianmiau.claude-pet");
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
if (win) win.showInactive();
});
app.whenReady().then(() => {
pets = discoverPets();
if (pets.length === 0) {
dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包(pet.json + spritesheet.webp)放到:\n${config.petSources.join("\n")}`);
}
createWindow();
createTray();
startServer();
startCursorPolling();
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
});
app.on("window-all-closed", () => {
// 留在系統匣,不結束
});
app.on("before-quit", () => {
if (cursorTimer) clearInterval(cursorTimer);
endDrag(false);
});
}
+174
View File
@@ -0,0 +1,174 @@
{
"name": "claude-pet",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-pet",
"version": "0.1.0",
"devDependencies": {
"electron": "^43.4.1"
}
},
"node_modules/@electron-internal/extract-zip": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/get": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^3.0.0",
"graceful-fs": "^4.2.11",
"progress": "^2.0.3",
"semver": "^7.6.3",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=22.12.0"
},
"optionalDependencies": {
"undici": "^7.24.4"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/electron": {
"version": "43.4.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-43.4.1.tgz",
"integrity": "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0"
},
"bin": {
"electron": "cli.js",
"install-electron": "install.js"
},
"engines": {
"node": ">= 22.12.0"
}
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "claude-pet",
"version": "0.1.0",
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 29 個動作列 + 16 方向追視)",
"main": "main.js",
"private": true,
"scripts": {
"start": "electron .",
"hooks:install": "node scripts/install-hooks.js",
"hooks:uninstall": "node scripts/uninstall-hooks.js",
"autostart:install": "node scripts/autostart.js install",
"autostart:uninstall": "node scripts/autostart.js uninstall",
"autostart:status": "node scripts/autostart.js status"
},
"devDependencies": {
"electron": "^43.4.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "xiao-nian",
"displayName": "小念",
"description": "一個阿呆",
"spriteVersionNumber": 2,
"spritesheetPath": "spritesheet.webp"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+35
View File
@@ -0,0 +1,35 @@
"use strict";
const { contextBridge, ipcRenderer } = require("electron");
const SEND = new Set([
"pet:ready",
"pet:ignore-mouse",
"pet:drag-start",
"pet:drag-end",
"pet:context-menu",
"pet:tray-icon",
"pet:state",
"pet:log",
]);
const ON = new Set([
"pet:init",
"pet:load",
"pet:scale",
"pet:settings",
"pet:event",
"pet:cursor",
"pet:drag-move",
]);
contextBridge.exposeInMainWorld("pet", {
send(channel, data) {
if (SEND.has(channel)) ipcRenderer.send(channel, data);
},
on(channel, callback) {
if (!ON.has(channel)) return () => {};
const listener = (_event, data) => callback(data);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
});
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" />
<title>Claude Pet</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="stage">
<div id="bubble" class="hidden"><span id="bubble-text"></span></div>
<canvas id="sprite" width="192" height="208"></canvas>
</div>
<script src="renderer.js"></script>
</body>
</html>
+479
View File
@@ -0,0 +1,479 @@
"use strict";
// Claude Pet rendererCodex V2 pet contract 的動畫引擎 + 狀態機
// 規格來源:8 欄 x 11 列、192x208 cell,列 0-8 標準動作、列 9-10 為 16 個順時針看向方向(000 = 正上方)
const CELL_W = 192;
const CELL_H = 208;
const ROW = {
idle: 0,
"running-right": 1,
"running-left": 2,
waving: 3,
jumping: 4,
failed: 5,
waiting: 6,
running: 7,
review: 8,
};
// 每一幀的毫秒數(Codex V2 animation-rows 合約)
const TIMINGS = {
idle: [280, 110, 110, 140, 140, 320],
"running-right": [120, 120, 120, 120, 120, 120, 120, 220],
"running-left": [120, 120, 120, 120, 120, 120, 120, 220],
waving: [140, 140, 140, 280],
jumping: [140, 140, 140, 140, 280],
failed: [140, 140, 140, 140, 140, 140, 140, 240],
waiting: [150, 150, 150, 150, 150, 260],
running: [120, 120, 120, 120, 120, 220],
review: [150, 150, 150, 150, 150, 280],
};
const LOOK_DEADZONE = 56; // 游標離寵物中心太近 → 回到 idle
const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看
const REVIEW_DURATION = 6000; // Stop 後 review 多久回 idle
const SESSION_STALE = 30 * 60 * 1000;
const PRIORITY = { waiting: 3, running: 2, review: 1, idle: 0 };
const canvas = document.getElementById("sprite");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const bubble = document.getElementById("bubble");
const bubbleText = document.getElementById("bubble-text");
let sheet = null;
let petInfo = null;
let scale = 1;
let followCursor = true;
const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil }
let overlay = null; // 一次性動作:{ anim, loopsLeft, onDone }
let drag = null; // 拖曳中:{ dir: "left" | "right" }
const look = { idx: null, lastMoveAt: 0 };
let player = { anim: "idle", frame: 0, frameStart: performance.now() };
let prevBase = "idle";
let bubbleTimer = null;
let bubbleSticky = false;
let ignoringMouse = true;
let pressed = null;
let lastReported = "";
function log(msg) {
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
}
// ---------- 載入 ----------
function loadPet(pet) {
petInfo = pet;
sheet = null;
const img = new Image();
img.onload = () => {
sheet = img;
makeTrayIcon();
log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`);
};
img.onerror = () => log(`spritesheet load failed for ${pet.id}`);
img.src = pet.dataUrl;
}
function applyScale(s) {
scale = s;
canvas.width = Math.round(CELL_W * s);
canvas.height = Math.round(CELL_H * s);
canvas.style.width = `${canvas.width}px`;
canvas.style.height = `${canvas.height}px`;
}
function cellHasPixels(row, col) {
const c = document.createElement("canvas");
c.width = 8; c.height = 8;
const g = c.getContext("2d");
g.drawImage(sheet, col * CELL_W + CELL_W / 2 - 24, row * CELL_H + CELL_H / 2 - 24, 48, 48, 0, 0, 8, 8);
const d = g.getImageData(0, 0, 8, 8).data;
for (let i = 3; i < d.length; i += 4) if (d[i] > 30) return true;
return false;
}
function makeTrayIcon() {
if (!sheet) return;
try {
const col = petInfo.version === 2 && cellHasPixels(0, 6) ? 6 : 0;
const c = document.createElement("canvas");
c.width = 32; c.height = 32;
const g = c.getContext("2d");
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = "high";
// 取上半身(頭部)做成方形 icon
const sw = 128, sh = 128;
g.drawImage(sheet, col * CELL_W + (CELL_W - sw) / 2, 4, sw, sh, 0, 0, 32, 32);
window.pet.send("pet:tray-icon", c.toDataURL("image/png"));
} catch (err) {
log(`tray icon failed: ${err.message}`);
}
}
// ---------- 狀態 ----------
function session(id, cwd) {
const key = id || "default";
let s = sessions.get(key);
if (!s) {
s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null, reviewUntil: 0 };
sessions.set(key, s);
}
if (cwd) s.cwd = cwd;
s.updatedAt = Date.now();
return s;
}
function setState(s, state) {
s.state = state;
s.updatedAt = Date.now();
}
function baseState() {
const now = Date.now();
let best = "idle";
for (const [id, s] of sessions) {
if (now - s.updatedAt > SESSION_STALE) { sessions.delete(id); continue; }
if (s.state === "review" && now > s.reviewUntil) s.state = "idle";
if (PRIORITY[s.state] > PRIORITY[best]) best = s.state;
}
return best;
}
function play(anim, loops = 1, onDone = null) {
overlay = { anim, loopsLeft: loops, onDone };
player = { anim, frame: 0, frameStart: performance.now() };
}
function resolveAnim() {
if (drag) return drag.dir === "left" ? "running-left" : "running-right";
if (overlay) return overlay.anim;
const base = baseState();
if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
return base;
}
function projectName(cwd) {
if (!cwd) return "";
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
}
// ---------- 氣泡 ----------
function say(text, ms, sticky = false) {
clearTimeout(bubbleTimer);
bubbleText.textContent = text;
bubble.title = text;
bubble.classList.remove("hidden");
bubbleSticky = sticky;
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
}
function hideBubble() {
clearTimeout(bubbleTimer);
bubble.classList.add("hidden");
bubbleSticky = false;
}
// ---------- 事件 ----------
function handleEvent(p) {
if (!p || typeof p !== "object") return;
if (p.event === "__test") return handleTest(p);
const s = session(p.sessionId, p.cwd);
const proj = projectName(s.cwd);
const suffix = proj ? `${proj}` : "";
switch (p.event) {
case "SessionStart":
setState(s, "idle");
if (!p.source || p.source === "startup") {
play("waving", 1);
say(`👋 嗨!${suffix}`, 3000);
}
break;
case "UserPromptSubmit":
setState(s, "running");
hideBubble();
break;
case "PreToolUse":
if (p.toolName === "AskUserQuestion") {
setState(s, "waiting");
say(`❓ 有問題想問你${suffix}`, 0, true);
} else if (p.toolName === "ExitPlanMode") {
setState(s, "waiting");
say(`📋 計畫等你確認${suffix}`, 0, true);
} else {
setState(s, "running");
}
break;
case "PostToolUse":
setState(s, "running");
break;
case "PostToolUseFailure":
setState(s, "running");
play("failed", 1);
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
break;
case "PermissionRequest":
setState(s, "waiting");
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true);
break;
case "Notification": {
const t = p.notificationType;
if (t === "permission_prompt") {
setState(s, "waiting");
say(`🔐 需要你授權${suffix}`, 0, true);
} else if (t === "idle_prompt") {
setState(s, "waiting");
say(`💤 在等你回覆${suffix}`, 0, true);
} else if (t === "elicitation_dialog") {
setState(s, "waiting");
say(`❓ 有問題想問你${suffix}`, 0, true);
} else if (p.message) {
say(`💬 ${p.message}`, 4000);
}
break;
}
case "Stop":
if (p.stopHookActive) break;
setState(s, "review");
s.reviewUntil = Date.now() + REVIEW_DURATION;
play("jumping", 1, () => say(`✅ 完成!${suffix}`, 8000));
break;
case "StopFailure":
setState(s, "idle");
play("failed", 2);
say(`${p.error || "出錯了"}`, 8000);
break;
case "SubagentStart":
case "SubagentStop":
setState(s, "running");
break;
case "PreCompact":
setState(s, "running");
say("🗜️ 整理記憶中…", 3000);
break;
case "SessionEnd":
sessions.delete(p.sessionId || "default");
if (sessions.size === 0) {
hideBubble();
say("👋 掰掰", 2000);
}
break;
default:
break;
}
report();
}
function handleTest(p) {
const anim = p.anim;
if (anim === "clear") {
sessions.delete("test");
overlay = null;
hideBubble();
} else if (anim === "waving" || anim === "jumping" || anim === "failed") {
play(anim, anim === "failed" ? 2 : 1);
} else if (anim in PRIORITY) {
const s = session("test");
setState(s, anim);
if (anim === "review") s.reviewUntil = Date.now() + 60000;
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
else if (anim === "idle") hideBubble();
}
report();
}
function report() {
const base = baseState();
const snapshot = JSON.stringify({ anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null });
if (snapshot !== lastReported) {
lastReported = snapshot;
window.pet.send("pet:state", JSON.parse(snapshot));
}
}
// ---------- 動畫主迴圈 ----------
function tick(now) {
const anim = resolveAnim();
if (anim !== player.anim) {
player = { anim, frame: 0, frameStart: now };
report();
}
if (anim !== "look") {
const t = TIMINGS[anim];
if (now - player.frameStart > 5000) player.frameStart = now; // 從休眠醒來,避免狂追幀
let guard = 0;
while (now - player.frameStart >= t[player.frame] && guard++ < 64) {
player.frameStart += t[player.frame];
player.frame += 1;
if (player.frame >= t.length) {
player.frame = 0;
if (overlay && overlay.anim === anim) {
overlay.loopsLeft -= 1;
if (overlay.loopsLeft <= 0) {
const done = overlay.onDone;
overlay = null;
if (done) done();
break;
}
}
}
}
}
const base = baseState();
if (prevBase === "waiting" && base !== "waiting" && bubbleSticky) hideBubble();
prevBase = base;
draw(anim);
requestAnimationFrame(tick);
}
function draw(anim) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!sheet) return;
let row;
let col;
if (anim === "look") {
row = 9 + (look.idx >> 3);
col = look.idx & 7;
} else {
row = ROW[anim];
col = player.frame;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height);
}
// ---------- 看向游標 ----------
window.pet.on("pet:cursor", ({ dx, dy }) => {
const now = Date.now();
const dist = Math.hypot(dx, dy);
if (dist < LOOK_DEADZONE * Math.max(scale, 0.75)) {
look.idx = null;
look.lastMoveAt = now;
return;
}
// 000 = 正上方,順時針
const angle = (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
const candidate = Math.round(angle / 22.5) % 16;
if (look.idx === null) {
look.idx = candidate;
} else if (candidate !== look.idx) {
// 加一點遲滯,避免在邊界抖動
let diff = Math.abs(angle - look.idx * 22.5);
diff = Math.min(diff, 360 - diff);
if (diff > 11.25 + 3) look.idx = candidate;
}
look.lastMoveAt = now;
});
setInterval(() => {
if (look.idx !== null && Date.now() - look.lastMoveAt > LOOK_IDLE_AFTER) look.idx = null;
}, 500);
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
function overOpaque(x, y) {
if (!bubble.classList.contains("hidden")) {
const r = bubble.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true;
}
const r = canvas.getBoundingClientRect();
if (x < r.left || x >= r.right || y < r.top || y >= r.bottom) return false;
const px = Math.floor((x - r.left) * (canvas.width / r.width));
const py = Math.floor((y - r.top) * (canvas.height / r.height));
try {
return ctx.getImageData(px, py, 1, 1).data[3] > 20;
} catch {
return true;
}
}
function setIgnore(ignore) {
if (ignore === ignoringMouse) return;
ignoringMouse = ignore;
window.pet.send("pet:ignore-mouse", ignore);
canvas.classList.toggle("grab", !ignore);
}
window.addEventListener("mousemove", (e) => {
if (pressed) {
if (!pressed.moved && Math.hypot(e.screenX - pressed.sx, e.screenY - pressed.sy) > 4) {
pressed.moved = true;
drag = { dir: "right" };
canvas.classList.add("grabbing");
window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
}
return;
}
setIgnore(!overOpaque(e.clientX, e.clientY));
});
document.addEventListener("mouseout", (e) => {
if (!e.relatedTarget && !pressed) setIgnore(true);
});
window.addEventListener("mousedown", (e) => {
if (e.button === 0) {
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false };
} else if (e.button === 2) {
window.pet.send("pet:context-menu");
}
});
window.addEventListener("mouseup", (e) => {
if (e.button !== 0 || !pressed) return;
const wasDrag = pressed.moved;
pressed = null;
if (wasDrag) {
drag = null;
canvas.classList.remove("grabbing");
window.pet.send("pet:drag-end");
} else {
onClick();
}
});
window.addEventListener("contextmenu", (e) => e.preventDefault());
window.pet.on("pet:drag-move", ({ dx }) => {
if (drag && dx) drag.dir = dx < 0 ? "left" : "right";
});
function onClick() {
if (!bubble.classList.contains("hidden") && !bubbleSticky) {
hideBubble();
return;
}
if (!overlay) play("waving", 1);
}
// ---------- 初始化 ----------
window.pet.on("pet:init", ({ pet, scale: s, followCursor: f }) => {
followCursor = f !== false;
applyScale(s || 1);
if (pet) loadPet(pet);
else log("no pet available");
});
window.pet.on("pet:load", ({ pet }) => { if (pet) loadPet(pet); });
window.pet.on("pet:scale", ({ scale: s }) => applyScale(s));
window.pet.on("pet:settings", (st) => {
if (st && "followCursor" in st) followCursor = !!st.followCursor;
});
window.pet.on("pet:event", handleEvent);
window.pet.send("pet:ready");
requestAnimationFrame(tick);
+67
View File
@@ -0,0 +1,67 @@
html, body {
margin: 0;
height: 100%;
background: transparent;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
cursor: default;
font-family: "Segoe UI", "Microsoft JhengHei", "PingFang TC", system-ui, sans-serif;
}
#stage {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 8px;
}
#bubble {
position: relative;
max-width: calc(100% - 12px);
margin-bottom: 10px;
padding: 6px 11px;
background: rgba(255, 255, 255, 0.97);
color: #222;
border-radius: 12px;
font-size: 13px;
line-height: 1.35;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.18s ease, transform 0.18s ease;
cursor: pointer;
}
#bubble::after {
content: "";
position: absolute;
left: 50%;
bottom: -6px;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(255, 255, 255, 0.97);
border-bottom: 0;
}
#bubble.hidden {
opacity: 0;
transform: translateY(4px);
pointer-events: none;
}
#sprite {
display: block;
}
#sprite.grab {
cursor: grab;
}
#sprite.grabbing {
cursor: grabbing;
}
+20
View File
@@ -0,0 +1,20 @@
"use strict";
const autostart = require("../lib/autostart");
const action = process.argv[2] || "status";
try {
if (action === "install") {
const cmd = autostart.install();
console.log(`已加入開機自動啟動(${autostart.RUN_KEY}\\${autostart.VALUE_NAME}`);
console.log(cmd);
} else if (action === "uninstall") {
console.log(autostart.uninstall() ? "已移除開機自動啟動。" : "原本就沒有設定開機自動啟動。");
} else {
const installed = autostart.isInstalled();
console.log(installed ? `開機自動啟動:已啟用\n${autostart.currentValue()}` : "開機自動啟動:未啟用");
}
} catch (err) {
console.error(err.message || err);
process.exitCode = 1;
}
+13
View File
@@ -0,0 +1,13 @@
"use strict";
const installer = require("../lib/hooks-installer");
try {
const r = installer.install();
console.log(`已安裝 ${r.events.length} 個 Claude Code hook 事件到 ${r.settingsPath}`);
console.log(`hook 指令:${r.command}`);
if (r.backupPath) console.log(`原設定備份:${r.backupPath}`);
console.log("注意:已開啟的 Claude Code session 要重新啟動才會載入新的 hooks。");
} catch (err) {
console.error(err.message || err);
process.exitCode = 1;
}
+11
View File
@@ -0,0 +1,11 @@
"use strict";
const installer = require("../lib/hooks-installer");
try {
const r = installer.uninstall();
console.log(`已從 ${r.settingsPath} 移除 ${r.removed} 個 Claude Pet hook。`);
if (r.backupPath) console.log(`原設定備份:${r.backupPath}`);
} catch (err) {
console.error(err.message || err);
process.exitCode = 1;
}