commit b1d57249f8cb42122ebc32d0df46aed9ee54f62b Author: JianMiau Date: Fri Aug 21 10:32:39 2026 +0800 初始提交: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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..047b409 --- /dev/null +++ b/README.md @@ -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 的 spritesheet,9 個標準動作列 + 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.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 | 0–5 | 280, 110, 110, 140, 140, 320 | +| 1 | running-right | 0–7 | 120 ×7, 220 | +| 2 | running-left | 0–7 | 120 ×7, 220 | +| 3 | waving | 0–3 | 140 ×3, 280 | +| 4 | jumping | 0–4 | 140 ×4, 280 | +| 5 | failed | 0–7 | 140 ×7, 240 | +| 6 | waiting | 0–5 | 150 ×5, 260 | +| 7 | running(工作中,不是跑步) | 0–5 | 120 ×5, 220 | +| 8 | review | 0–5 | 150 ×5, 280 | +| 9 | look A | 0–7 | 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5 度 | +| 10 | look B | 0–7 | 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\\`,本程式預設掃描該資料夾,選單「切換寵物 → 重新掃描」就會出現。 +2. **手動放入** — 把寵物資料夾複製到 `pets\\`(選單「開啟寵物資料夾」可直接打開)。 +3. **其他位置** — 在設定檔 `petSources` 加路徑。 + +切換後立即生效,不用重啟。 + +## 設定檔 + +位置:`%USERPROFILE%\.claude-pet\config.json`(第一次啟動後自動產生,選單「開啟設定檔」可直接開)。 + +| 欄位 | 預設 | 說明 | +|---|---|---| +| `activePetId` | `"xiao-nian"` | 目前使用的寵物 id | +| `petSources` | `[/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 ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──POST /event──▶ 127.0.0.1:17333 + │ Electron main(main.js) + │ 視窗 / 系統匣 / 游標輪詢 / 拖曳 / 設定 + ▼ IPC + renderer/renderer.js + 狀態機 + 幀動畫 + 追視 + 氣泡 + alpha 點穿判定 +``` + +| 檔案 | 職責 | +|---|---| +| `main.js` | Electron 主程序:透明置頂視窗、系統匣選單、HTTP 伺服器、游標輪詢(50 ms)、拖曳(60 fps)、設定讀寫、寵物掃描 | +| `preload.js` | 以 contextBridge 暴露白名單 IPC 頻道給 renderer(sandbox 模式) | +| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠 alpha 判定 | +| `renderer/index.html`、`style.css` | 版面與氣泡樣式(CSP 僅允許 self / data:) | +| `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST;800 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` 的 payload(hook 腳本送出的格式): + +```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` skill(Codex V2 pet contract)。 +- 內附寵物「小念」由 Codex `/hatch` 產生。 diff --git a/docs/states.png b/docs/states.png new file mode 100644 index 0000000..4de3db4 Binary files /dev/null and b/docs/states.png differ diff --git a/hook/claude-pet-hook.js b/hook/claude-pet-hook.js new file mode 100644 index 0000000..749d991 --- /dev/null +++ b/hook/claude-pet-hook.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +"use strict"; +// Claude Code hook → Claude Pet +// 讀 stdin 的 hook JSON,POST 到本機的寵物 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); +})(); diff --git a/lib/autostart.js b/lib/autostart.js new file mode 100644 index 0000000..005bc7a --- /dev/null +++ b/lib/autostart.js @@ -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 }; diff --git a/lib/hooks-installer.js b/lib/hooks-installer.js new file mode 100644 index 0000000..004f0e2 --- /dev/null +++ b/lib/hooks-installer.js @@ -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 }; diff --git a/main.js b/main.js new file mode 100644 index 0000000..177600c --- /dev/null +++ b/main.js @@ -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); + }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b38b3eb --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8a6c2e4 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "claude-pet", + "version": "0.1.0", + "description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 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" + } +} diff --git a/pets/xiao-nian/pet.json b/pets/xiao-nian/pet.json new file mode 100644 index 0000000..15455b5 --- /dev/null +++ b/pets/xiao-nian/pet.json @@ -0,0 +1,7 @@ +{ + "id": "xiao-nian", + "displayName": "小念", + "description": "一個阿呆", + "spriteVersionNumber": 2, + "spritesheetPath": "spritesheet.webp" +} diff --git a/pets/xiao-nian/spritesheet.webp b/pets/xiao-nian/spritesheet.webp new file mode 100644 index 0000000..c03db29 Binary files /dev/null and b/pets/xiao-nian/spritesheet.webp differ diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..cd5195b --- /dev/null +++ b/preload.js @@ -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); + }, +}); diff --git a/renderer/index.html b/renderer/index.html new file mode 100644 index 0000000..5e15f0d --- /dev/null +++ b/renderer/index.html @@ -0,0 +1,16 @@ + + + + + + Claude Pet + + + +
+ + +
+ + + diff --git a/renderer/renderer.js b/renderer/renderer.js new file mode 100644 index 0000000..36f382d --- /dev/null +++ b/renderer/renderer.js @@ -0,0 +1,479 @@ +"use strict"; +// Claude Pet renderer:Codex 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); diff --git a/renderer/style.css b/renderer/style.css new file mode 100644 index 0000000..55a8cc3 --- /dev/null +++ b/renderer/style.css @@ -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; +} diff --git a/scripts/autostart.js b/scripts/autostart.js new file mode 100644 index 0000000..edb896c --- /dev/null +++ b/scripts/autostart.js @@ -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; +} diff --git a/scripts/install-hooks.js b/scripts/install-hooks.js new file mode 100644 index 0000000..5efe980 --- /dev/null +++ b/scripts/install-hooks.js @@ -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; +} diff --git a/scripts/uninstall-hooks.js b/scripts/uninstall-hooks.js new file mode 100644 index 0000000..af61c74 --- /dev/null +++ b/scripts/uninstall-hooks.js @@ -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; +}