SLOPSHOPPER

codex-pet

Your Codex pet in Claude Code: above the prompt and as a desktop pet that follows both Claude Code and Codex.

newbandguardcommandtoastprocess
v0.3.0MITupdated 2026-10-08John-owo/claude-code-with-codex-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-pet
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ codex-pet: Codex 寵物(~/.codex/pets): ⎿ codex-pet: (沒有找到) ⎿ codex-pet: ⎿ codex-pet: /pet <id> 切換 · /pet codex 跟隨 Codex 的選擇 · /pet-on · /pet-off 開關桌寵 · /pet-size 大小 · /pet-mode 懸停|物理 · /pet hide| ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-with-codex-pet

繁體中文 · English

讓 Claude Code(CC)和 Codex 共用同一隻桌寵的 Claude Code mod。

它用的是 Codex 自己的寵物(~/.codex/pets),會在桌面上放一隻永遠在最上層的桌寵,同時追蹤 CC 和 Codex 每個對話在做什麼,外觀和行為仿照 Codex App 原版的桌寵。

這是個人專案,跟 OpenAI 或 Anthropic 沒有關係。Claude Code 的 mod 介面(function hooks)目前還是 early access,之後的版本可能會變動。

截圖

<table> <tr> <td align="center"><img src="docs/screenshots/desktop-pet.png" alt="桌寵收合時:寵物、角標和最緊急的一張卡片" width="320"><br>平常:寵物、角標,加上最緊急的一張卡片</td> <td align="center"><img src="docs/screenshots/activity-list.png" alt="滑鼠移到寵物上時展開的活動列表" width="320"><br>滑鼠移上去:展開所有 CC 和 Codex 對話</td> </tr> </table>

截圖由桌寵自己的繪圖程式產生,卡片是範例資料。寵物是 codex-pet-share 專案裡的 Debug Duck(MIT 授權)。

跟 Codex 寵物社群通用

這個 mod 不用自己的寵物格式,直接讀 Codex 的寵物資料夾 ${CODEX_HOME:-~/.codex}/pets/<id>/(pet.json + spritesheet.webp),所以:

  • Codex 能用的寵物,這裡都能用:你在 Codex 裡選的、用 hatch-pet 孵的、自己做的,都會自動出現。
  • v1 和 v2 都支援:v1 是 1536×1872(9 列),v2 是 1536×2288(9 列 + 16 方向的兩列)。
  • codex-pets.net 上的社群寵物可以直接用。網站上每隻寵物都有安裝指令,裝完就會出現在 /pet list:
  npx codex-pets add <寵物 id>

寵物 id 就是網址裡的那段,例如 https://codex-pets.net/#/pets/nino 的 nino。這個指令會把寵物裝到 ~/.codex/pets/<id>/,跟 Codex 和這個 mod 讀的是同一個資料夾,所以裝一次,Codex 和 CC 都看得到。裝好後用 /pet <id> 切換,或在 Codex 裡選它。

功能

  • 同一隻寵物:直接讀 Codex 的寵物資料夾,用 hatch-pet 孵出來的寵物 CC 也看得到。預設跟著 Codex 選的寵物;Codex 選的是雲端寵物時,會用 Codex 的遷移紀錄找回本機那份圖。
  • 活動列表:每個 CC 對話和 Codex 對話各一張卡片,顯示標題、狀態(需要你 / 卡住了 / 完成 / 工作中)、正在做什麼、多久以前。順序跟原版一樣:需要你 → 卡住了 → 完成 → 工作中。
  • 點卡片跳過去:CC 用 claude://code/continue?session=…,Codex 用 codex://threads/…。打開過的「完成」卡片會自動清掉。
  • 寵物動作跟著最緊急的那張卡片走;剛完成會跳一下,拖曳時會往左右跑。
  • 角標顯示有幾件事需要你處理;滑鼠移到寵物上會展開列表。
  • 任意大小:30%~300%(100% 是「中」)。右鍵選單有小(75%)、中、大(130%)三種預設,選「自訂…」會開一個滑桿視窗,拉的時候寵物會跟著變大變小。也可以用 /pet-size 指令設定。
  • 兩種擺放模式(右鍵選單的「擺放模式」或 /pet-mode):
  • 懸停(預設):拖到哪就停在哪。
  • 物理:放開後會掉下來,落地彈 2~3 下,最後停在工作列上方。拖著甩出去再放開,寵物會飛出去,撞到螢幕邊緣會反彈;半空中可以抓住它。多螢幕時會落在寵物所在那個螢幕的工作列上;旁邊有其他螢幕的那一側不算牆,可以丟過去。
  • 右鍵選單:大小、擺放模式、固定展開、清掉已完成的卡片、揮手、重新載入、隱藏、關閉。
  • Win+Alt+O 顯示或隱藏桌寵(跟 Codex 的 Win+Alt+P 錯開)。
  • Windows 關掉「動畫效果」時只顯示靜止畫面;物理模式下會直接落地,不會飛行或彈跳。
  • 大小、擺放模式和位置都會記住,下次打開還是一樣。

需求

  • Windows 10 / 11
  • Claude Code(桌面版 Code 分頁或 CLI),版本要支援 function hooks 的 mod
  • Python 3 和 Pillow(py -3 -m pip install pillow),桌寵視窗用 tkinter 畫
  • Codex App,以及至少一隻放在 ~/.codex/pets/<名字>/ 的寵物(pet.json + spritesheet.webp)

安裝

在 PowerShell 裡執行:

git clone https://github.com/John-owo/claude-code-with-codex-pet.git "$env:USERPROFILE\.claude\mods\codex-pet"
cd "$env:USERPROFILE\.claude\mods\codex-pet"
powershell -ExecutionPolicy Bypass -File .\install.ps1

install.ps1 會:

  1. 檢查 Python 啟動器(py)、tkinter 和 Pillow;缺 Pillow 時問你要不要用 pip 裝。
  2. 把這個資料夾加進 ~/.claude/settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS,讓每個 CC 對話都載入它。其他設定都會保留,修改前會先備份成 settings.json.bak-codex-pet。
  3. 檢查 ~/.codex/pets 裡有沒有寵物。

裝好後開一個新的 CC 對話,桌寵就會出現。要移除的話,執行 powershell -ExecutionPolicy Bypass -File .\install.ps1 -Uninstall。

把 repo clone 到固定的位置,再在 ~/.claude/settings.json 的 env 加上這個資料夾(已經有其他資料夾的話,用 ; 隔開):

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<你的帳號>\\.claude\\mods\\codex-pet"
  }
}

指令

在 CC 輸入框裡:

指令作用
/pet 或 /pet list列出 ~/.codex/pets 裡的寵物
/pet <id>換成這隻寵物
/pet codex回到跟著 Codex 的選擇
/pet-on打開桌寵(也可用 /pet overlay on)
/pet-off關掉桌寵(也可用 /pet overlay off)(下次開新對話會再自動打開)
/pet-size <大小>設定桌寵大小:30~300 的百分比(例如 /pet-size 150),或 小 / 中 / 大。不加參數會顯示目前大小。也可用 /pet size …
/pet-mode <模式>切換擺放模式:懸停、物理,或 切換(在兩者之間切換)。不加參數會顯示目前模式。也可用 /pet mode …
/pet show / /pet hide顯示 / 隱藏 CC 輸入框上方的小寵物(有桌寵時預設隱藏)
/pet reload重新讀取寵物圖

在 Claude 桌面版的指令列表裡,/pet-on、/pet-off、/pet-size、/pet-mode 會顯示成 /codex-pet:pet-on 這類寫法。兩種寫法效果一樣,都由 mod 直接處理,不會多跑一次模型。

/pet-size 和 /pet-mode 會把設定寫進 ~/.codex-pet/request.json,桌寵在半秒內套用並記住。桌寵沒開的話,下次打開時套用。

設定

mod 的選項(userConfig):

選項預設說明
python空白有裝 Pillow 的 Python 路徑;空白時依序試 py -3、python3、python
pet空白指定寵物資料夾名稱;空白時跟著 Codex 的選擇
overlaytrue開對話時要不要自動打開桌寵

怎麼運作的

CC 對話 ──(mod 寫入)──> ~/.codex-pet/sessions/cc-<id>.json ─┐
                                                              ├─> 桌寵視窗 (overlay/pet_overlay.py)
Codex ──(Codex 自己寫的對話紀錄)── ~/.codex/sessions/... ────┘
  • hooks/register.tsx:CC 的 mod。依照 CC 的事件(開始回合、呼叫工具、等你核准、回合結束)更新這個對話的狀態檔,每 30 秒送一次心跳;也負責啟動桌寵。
  • overlay/pet_overlay.py:桌寵視窗本身(Win32 layered window,整個畫面用 Pillow 畫)。只會同時跑一隻;新版本會自動接手舊版本。
  • overlay/physics.py:物理模式的計算(重力、反彈、摩擦、甩出去的初速)和大小的換算,都是不依賴視窗的純函式。
  • overlay/sources.py:讀 CC 的狀態檔、Claude 桌面版的對話紀錄(拿標題和跳轉用的 id),以及 Codex 的 session_index.jsonl 和對話紀錄。
  • bake/bake_pet.py:找出要用的寵物,並把 spritesheet 切成影格。

所有資料都只在你自己的電腦上讀寫,不會傳到任何地方。桌寵的位置、大小、擺放模式等偏好存在 ~/.codex-pet/overlay.json,錯誤紀錄在 ~/.codex-pet/overlay-error.log。

已知限制

  • Codex 的對話紀錄裡沒有「等你核准」的事件,所以 Codex 卡片不會顯示「需要你」。
  • Codex 那邊讀過的「完成」狀態只存在 Codex App 裡,桌寵看不到;點卡片或按 × 可以清掉,3 小時後也會自動清掉。
  • 看向游標要 v2 寵物(有 16 方向那兩列圖)才有,目前還沒做。
  • 只在 Codex 端上傳過、本機沒有副本的雲端寵物讀不到圖。

開發

claude plugin validate .
claude plugin test .
py -3 -m unittest discover -s tests

最後一行跑桌寵物理和大小計算的測試(tests/test_physics.py)。

授權

MIT

Source 2 files
hooks/register.tsx 544 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mood, PetInfo, Status } from '../types'
5
6type Strip = { frames: string[]; durations: number[]; w: number; h: number }
7type Baked = {
8  ok: boolean
9  error?: string
10  pet?: PetInfo
11  available: { id: string; displayName: string }[]
12  states?: Record<string, Strip>
13  /** Set when Codex's selected pet has no local spritesheet and another was used. */
14  note?: string | null
15}
16
17const mood = atom({ plugin: 'codex-pet', key: 'mood' } as const, 'idle' as Mood)
18const pet = atom({ plugin: 'codex-pet', key: 'pet' } as const, null)
19const bakedAt = atom({ plugin: 'codex-pet', key: 'bakedAt' } as const, 0)
20const error = atom({ plugin: 'codex-pet', key: 'error' } as const, null)
21const isHidden = atom({ plugin: 'codex-pet', key: 'isHidden' } as const, false)
22const frame = atom({ plugin: 'codex-pet', key: 'frame' } as const, 0)
23
24const STORE_PET = 'pet'
25const STORE_HIDDEN = 'hidden'
26
27const LABELS: Record<Mood, string> = {
28  idle: '待命中',
29  running: '工作中…',
30  review: '看資料中…',
31  waiting: '等你回覆',
32  failed: '出錯了',
33  jumping: '完成!',
34  waving: '嗨!',
35}
36
37/** Codex's own state names; the atlas rows the bake script emits. */
38const SPRITE: Record<Mood, string> = {
39  idle: 'idle',
40  running: 'running',
41  review: 'review',
42  waiting: 'waiting',
43  failed: 'failed',
44  jumping: 'jumping',
45  waving: 'waving',
46}
47
48const LOOKING = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'LS'])
49const ASKING = new Set(['AskUserQuestion', 'ExitPlanMode'])
50
51/**
52 * One frame as a static SVG. The desktop draws a non-interactive Svg as an
53 * image, which shows an embedded raster but runs no SMIL, so the mod's own
54 * timer steps the frames.
55 */
56export function frameSvg(href: string, w: number, h: number): string {
57  return (
58    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">` +
59    `<image href="${href}" x="0" y="0" width="${w}" height="${h}"/></svg>`
60  )
61}
62
63// Sprites are large; they live in the module and `bakedAt` tells drawings they changed.
64let strips: Record<string, Strip> = {}
65let ticker: { cancel: () => void } | undefined
66let heartbeat: { cancel: () => void } | undefined
67let petDir = '' // ~/.codex-pet, the folder the desktop pet and this mod share
68let shared = '' // ~/.codex-pet/sessions/cc-<session id>.json: the desktop pet's view of this session
69let sessionId = ''
70/** What the desktop pet's card for this session says, in the Codex pet's statuses. */
71let activity: { status: Status; action: string; statusAt: number; firstPrompt: string; cwd: string } = {
72  status: 'idle', action: '', statusAt: 0, firstPrompt: '', cwd: '',
73}
74let edited = new Set<string>()
75let available: Baked['available'] = []
76let inTurn = false
77let isAsking = false
78let settle: { cancel: () => void } | undefined
79let config: { python: string; pet: string; overlay: boolean } = { python: '', pet: '', overlay: true }
80
81function base(): Mood {
82  return isAsking ? 'waiting' : inTurn ? 'running' : 'idle'
83}
84
85/** Sets the mood and tells the desktop overlay (shared with Codex) about it. */
86async function moodTo($: EngineInterface, next: Mood) {
87  await update($, mood, () => next)
88  await publish($)
89}
90
91/** Moves this session's card on the desktop pet to `status`, saying what it is doing. */
92async function report($: EngineInterface, status: Status, action = activity.action) {
93  const changed = status !== activity.status
94  activity = { ...activity, status, action, statusAt: changed ? Date.now() : activity.statusAt }
95  await publish($)
96}
97
98async function publish($: EngineInterface, ended = false) {
99  if (!shared) return
100  const current = await read($, pet)
101  const state = {
102    agent: 'cc',
103    id: sessionId,
104    cwd: activity.cwd,
105    firstPrompt: activity.firstPrompt,
106    status: ended ? 'ended' : activity.status,
107    action: activity.action,
108    statusAt: activity.statusAt,
109    mood: await read($, mood),
110    petId: current?.id ?? null,
111    updatedAt: Date.now(),
112  }
113  try {
114    await $.fs.write(shared, JSON.stringify(state))
115  } catch {
116    // The overlay is optional; the band above the prompt still shows the pet.
117  }
118}
119
120/** Starts the desktop pet unless one is running (it keeps a single instance itself). */
121async function launchOverlay($: EngineInterface) {
122  const script = `${$.plugin.root}/overlay/pet_overlay.py`
123  const pythonw = config.python ? config.python.replace(/python(\.exe)?$/i, 'pythonw$1') : ''
124  const argv = pythonw ? [pythonw, script] : ['pyw', '-3', script]
125  // `cmd /c start` leaves the pet holding run()'s output pipes, so run() waits until timeout even
126  // though the pet opened; Start-Process detaches it and returns at once.
127  const ps = (s: string) => `'${s.replace(/'/g, "''")}'`
128  const command = `Start-Process ${ps(argv[0]!)} -ArgumentList ${argv.slice(1).map(a => ps(/\s/.test(a) ? `"${a}"` : a)).join(',')}`
129  try {
130    await $.process.run(['powershell', '-NoProfile', '-Command', command], { timeoutMs: 15_000 })
131  } catch (err) {
132    $.ui.toast(`codex-pet: 桌寵沒開起來 (${String(err)})`)
133  }
134}
135
136async function overlayOn($: EngineInterface) {
137  await launchOverlay($)
138  await publish($)
139  return { text: '桌寵已開啟(已經開著就不會重複開)。/pet-off 可以關掉。' }
140}
141
142async function overlayOff($: EngineInterface) {
143  if (!petDir) return { text: '找不到 ~/.codex-pet,沒辦法通知桌寵。' }
144  // The desktop pet checks for this file twice a second and closes when it appears.
145  await $.fs.write(`${petDir}/takeover`, 'off')
146  return { text: '已通知桌寵關閉。之後開新的 CC 對話會再自動打開,/pet-on 可以手動叫回來。' }
147}
148
149const SIZE_MIN = 30
150const SIZE_MAX = 300
151const SIZE_NAMES: Record<string, number> = {
152  小: 75, 中: 100, 大: 130, small: 75, medium: 100, large: 130, s: 75, m: 100, l: 130,
153}
154// The desktop pet's own names; prefs from before sizes were percentages hold these.
155const LEGACY_SIZES: Record<string, number> = { 小: 73, 中: 100, 大: 129 }
156type Placement = 'hover' | 'physics'
157const MODE_NAMES: Record<string, Placement> = {
158  懸停: 'hover', 正常: 'hover', hover: 'hover', float: 'hover',
159  物理: 'physics', physics: 'physics', gravity: 'physics', drop: 'physics',
160}
161const MODE_TEXT: Record<Placement, string> = { hover: '懸停(拖到哪停到哪)', physics: '物理(放開會掉下來、彈跳)' }
162
163/** A size the person typed: 150, 150%, 小 / 中 / 大. Out of range or not a size: undefined. */
164export function parseSize(arg: string): number | undefined {
165  const text = arg.trim().toLowerCase()
166  if (text in SIZE_NAMES) return SIZE_NAMES[text]
167  const match = /^(\d+(?:\.\d+)?)\s*%?$/.exec(text)
168  if (!match) return undefined
169  const pct = Math.round(Number(match[1]))
170  return pct >= SIZE_MIN && pct <= SIZE_MAX ? pct : undefined
171}
172
173async function readJson($: EngineInterface, path: string): Promise<Record<string, unknown>> {
174  try {
175    const value = JSON.parse(await $.fs.read(path)) as unknown
176    return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
177  } catch {
178    return {}
179  }
180}
181
182/** The desktop pet's size and placement: what it saved, with any request it has not taken yet. */
183async function placement($: EngineInterface): Promise<{ size: number; mode: Placement }> {
184  const prefs = { ...(await readJson($, `${petDir}/overlay.json`)), ...(await readJson($, `${petDir}/request.json`)) }
185  const raw = prefs.size
186  const size = typeof raw === 'number' ? Math.round(raw) : typeof raw === 'string' ? (LEGACY_SIZES[raw] ?? 100) : 100
187  return { size, mode: prefs.mode === 'physics' ? 'physics' : 'hover' }
188}
189
190/**
191 * Sends a setting to the desktop pet, a separate process: it takes ~/.codex-pet/request.json
192 * at its next poll (twice a second), applies it and saves it in its own prefs; a pet that is
193 * not running takes it when it next opens.
194 */
195async function sendSetting($: EngineInterface, change: { size?: number; mode?: Placement }) {
196  const request = `${petDir}/request.json`
197  await $.fs.write(request, JSON.stringify({ ...(await readJson($, request)), ...change }))
198}
199
200const LATER = '桌寵開著的話馬上套用;沒開的話,下次打開時套用。'
201
202async function petSize($: EngineInterface, arg: string) {
203  if (!petDir) return { text: '找不到 ~/.codex-pet,沒辦法通知桌寵。' }
204  if (!arg.trim()) {
205    const now = await placement($)
206    return {
207      text: `桌寵現在是 ${now.size}%。\n用 /pet-size <${SIZE_MIN}~${SIZE_MAX}> 設定,例如 /pet-size 150;也可以用 小(75%)/ 中(100%)/ 大(130%)。`,
208    }
209  }
210  const size = parseSize(arg)
211  if (size === undefined) {
212    return { text: `「${arg.trim()}」不是可以用的大小。請輸入 ${SIZE_MIN}~${SIZE_MAX} 的數字(百分比),或 小 / 中 / 大。` }
213  }
214  await sendSetting($, { size })
215  return { text: `桌寵大小設成 ${size}%,會記住這個設定。${LATER}` }
216}
217
218async function petMode($: EngineInterface, arg: string) {
219  if (!petDir) return { text: '找不到 ~/.codex-pet,沒辦法通知桌寵。' }
220  const text = arg.trim().toLowerCase()
221  const now = await placement($)
222  if (!text) {
223    return {
224      text: `桌寵現在是${MODE_TEXT[now.mode]}模式。\n用 /pet-mode 懸停 或 /pet-mode 物理 切換,/pet-mode 切換 在兩者之間切換。`,
225    }
226  }
227  const mode = text === '切換' || text === 'toggle' ? (now.mode === 'hover' ? 'physics' : 'hover') : MODE_NAMES[text]
228  if (!mode) return { text: `沒有「${arg.trim()}」這個模式。可以用:懸停、物理、切換。` }
229  await sendSetting($, { mode })
230  return { text: `擺放模式改成${MODE_TEXT[mode]},會記住這個設定。${LATER}` }
231}
232
233async function setMood($: EngineInterface, next: Mood) {
234  settle?.cancel()
235  settle = undefined
236  await moodTo($, next)
237}
238
239/** A short reaction, then back to whatever CC is doing. */
240async function flash($: EngineInterface, next: Mood, ms: number) {
241  await setMood($, next)
242  settle = $.clock.after(ms, () => {
243    settle = undefined
244    void moodTo($, base()).catch(() => undefined)
245  })
246}
247
248const base_ = (path: string) => path.split(/[\\/]/).pop() ?? path
249
250/** A few words on what a tool call does, for the session's card. */
251function describe(tool: string, input: Record<string, unknown>): string {
252  const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
253  switch (tool) {
254    case 'Bash':
255    case 'PowerShell':
256      return `執行 ${text('description') || text('command').split('\n')[0]!.slice(0, 40)}`
257    case 'Edit':
258    case 'Write':
259    case 'NotebookEdit':
260      return `編輯 ${base_(text('file_path') || text('notebook_path'))}`
261    case 'Read':
262      return `讀 ${base_(text('file_path'))}`
263    case 'Grep':
264    case 'Glob':
265      return `搜尋程式碼 ${text('pattern').slice(0, 24)}`
266    case 'WebSearch':
267      return `搜尋「${text('query').slice(0, 24)}」`
268    case 'WebFetch':
269      return '讀網頁'
270    case 'Agent':
271    case 'Task':
272      return `派出子代理:${text('description').slice(0, 30)}`
273    default:
274      return tool.startsWith('mcp__') ? `呼叫 ${tool.split('__').pop()}` : `使用 ${tool}`
275  }
276}
277
278function currentStrip(now: Mood): Strip | undefined {
279  return strips[SPRITE[now]] ?? strips.idle
280}
281
282/** Advances `frame` on the current mood's own frame timings, for as long as the module lives. */
283async function step($: EngineInterface) {
284  const strip = currentStrip(await read($, mood))
285  let wait = 250
286  if (strip && !(await read($, isHidden))) {
287    const next = ((await read($, frame)) + 1) % strip.frames.length
288    await update($, frame, () => next)
289    wait = strip.durations[next] ?? 150
290  }
291  ticker = $.clock.after(wait, () => void step($).catch(() => undefined))
292}
293
294function pythons(): string[][] {
295  return [...(config.python ? [[config.python]] : []), ['py', '-3'], ['python3'], ['python']]
296}
297
298async function bake($: EngineInterface) {
299  const chosen = ((await $.store.get(STORE_PET)) as string | undefined) || config.pet || 'auto'
300  const script = `${$.plugin.root}/bake/bake_pet.py`
301  let lastError = 'no Python found'
302
303  for (const argv of pythons()) {
304    let out: Baked
305    try {
306      const ran = await $.process.run([...argv, script, '--pet', chosen], { timeoutMs: 60_000 })
307      if (ran.exitCode !== 0) {
308        lastError = `${argv.join(' ')}: ${ran.stderr.trim().split('\n').pop() ?? 'failed'}`
309        continue
310      }
311      out = JSON.parse(ran.stdout) as Baked
312    } catch (err) {
313      lastError = `${argv.join(' ')}: ${String(err)}`
314      continue
315    }
316    available = out.available
317    if (!out.ok || !out.states || !out.pet) {
318      lastError = out.error ?? 'bake failed'
319      if (lastError.includes('Pillow')) continue
320      break
321    }
322    const baked = out.pet
323    if (out.note) $.ui.toast(out.note)
324    strips = out.states
325    await update($, pet, () => baked)
326    await update($, error, () => null)
327    await update($, bakedAt, () => Date.now())
328    await publish($)
329    return
330  }
331  await update($, error, () => lastError)
332}
333
334export const register: Register = (on, options) => {
335  const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
336  config = { python: text(options.python), pet: text(options.pet), overlay: options.overlay !== false }
337  strips = {}
338  ticker?.cancel()
339  ticker = undefined
340  heartbeat?.cancel()
341  heartbeat = undefined
342  inTurn = false
343  isAsking = false
344  settle = undefined
345
346  on('session.start', async ($, e, next) => {
347    await $.command.register({
348      name: 'pet',
349      description: 'Codex pet: /pet [list | <id> | codex | hide | show | reload]',
350    })
351    await $.command.register({ name: 'pet-on', description: 'Codex pet: 打開桌寵' })
352    await $.command.register({ name: 'pet-off', description: 'Codex pet: 關掉桌寵' })
353    await $.command.register({ name: 'pet-size', description: 'Codex pet: 設定桌寵大小', argumentHint: '<30~300 | 小 | 中 | 大>' })
354    await $.command.register({ name: 'pet-mode', description: 'Codex pet: 懸停或物理模式', argumentHint: '<懸停 | 物理 | 切換>' })
355    // With the desktop pet on, the band stays off unless /pet show turned it on.
356    const stored = await $.store.get(STORE_HIDDEN)
357    const hidden = typeof stored === 'boolean' ? stored : config.overlay
358    await update($, isHidden, () => hidden)
359    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
360    sessionId = await $.session.id()
361    petDir = home ? `${home}/.codex-pet` : ''
362    shared = petDir ? `${petDir}/sessions/cc-${sessionId}.json` : ''
363    const firstPrompt = (await $.store.get(`firstPrompt:${sessionId}`)) as string | undefined
364    activity = { status: 'idle', action: '', statusAt: Date.now(), firstPrompt: firstPrompt ?? '', cwd: e.cwd }
365    edited = new Set()
366    await bake($)
367    if (config.overlay) await launchOverlay($)
368    await flash($, 'waving', 2500)
369    if (!ticker) void step($).catch(() => undefined)
370    heartbeat ??= $.clock.every(30_000, () => void publish($).catch(() => undefined))
371
372    return next(e)
373  })
374
375  on('command.run', { command: 'pet' }, async ($, e) => {
376    const arg = e.args.trim()
377
378    if (arg === 'hide' || arg === 'show') {
379      await $.store.set(STORE_HIDDEN, arg === 'hide')
380      await update($, isHidden, () => arg === 'hide')
381      return { text: arg === 'hide' ? '寵物收起來了。' : '寵物出來了。' }
382    }
383    if (arg === '' || arg === 'list') {
384      const current = await read($, pet)
385      const rows = available.map(p => `${p.id === current?.id ? '▶' : ' '} ${p.id}  (${p.displayName})`)
386      return {
387        text:
388          `Codex 寵物(~/.codex/pets):\n${rows.join('\n') || '  (沒有找到)'}\n\n` +
389          '/pet <id> 切換 · /pet codex 跟隨 Codex 的選擇 · /pet-on · /pet-off 開關桌寵 · ' +
390          '/pet-size 大小 · /pet-mode 懸停|物理 · /pet hide|show · /pet reload',
391      }
392    }
393    const [sub, ...rest] = arg.split(/\s+/)
394    if (sub === 'size') return petSize($, rest.join(' '))
395    if (sub === 'mode') return petMode($, rest.join(' '))
396    if (arg === 'overlay' || arg === 'overlay on') return overlayOn($)
397    if (arg === 'overlay off') return overlayOff($)
398    if (arg === 'reload') {
399      await bake($)
400      const failed = await read($, error)
401      return { text: failed ? `重新載入失敗:${failed}` : '已重新載入。' }
402    }
403    if (arg === 'codex') {
404      await $.store.delete(STORE_PET)
405    } else {
406      if (!available.some(p => p.id === arg)) {
407        return { text: `找不到寵物「${arg}」。用 /pet list 看看有哪些。` }
408      }
409      await $.store.set(STORE_PET, arg)
410    }
411    await bake($)
412    await flash($, 'waving', 2500)
413    const now = await read($, pet)
414    return { text: now ? `現在的寵物:${now.displayName}` : `切換失敗:${await read($, error)}` }
415  })
416
417  // The desktop app lists commands it learns when the session initializes, before
418  // session.start registers the standalone commands; commands/*.md put them in that list
419  // as /codex-pet:pet-on and so on, and these hooks answer both spellings so the files'
420  // fallback text never reaches the model.
421  on('command.run', { command: 'pet-on' }, async $ => overlayOn($))
422  on('command.run', { command: 'codex-pet:pet-on' }, async $ => overlayOn($))
423  on('command.run', { command: 'pet-off' }, async $ => overlayOff($))
424  on('command.run', { command: 'codex-pet:pet-off' }, async $ => overlayOff($))
425  on('command.run', { command: 'pet-size' }, async ($, e) => petSize($, e.args))
426  on('command.run', { command: 'codex-pet:pet-size' }, async ($, e) => petSize($, e.args))
427  on('command.run', { command: 'pet-mode' }, async ($, e) => petMode($, e.args))
428  on('command.run', { command: 'codex-pet:pet-mode' }, async ($, e) => petMode($, e.args))
429
430  on('session.end', async ($, e, next) => {
431    await update($, mood, () => 'idle')
432    await publish($, true)
433    return next(e)
434  })
435
436  on('turn.start', async ($, e, next) => {
437    inTurn = true
438    isAsking = false
439    edited = new Set()
440    if (!activity.firstPrompt && e.text.trim()) {
441      activity.firstPrompt = e.text.trim().split('\n')[0]!.slice(0, 60)
442      await $.store.set(`firstPrompt:${sessionId}`, activity.firstPrompt)
443    }
444    await report($, 'running', '思考中')
445    await setMood($, 'running')
446    return next(e)
447  })
448
449  on('tool.call', async ($, e, next) => {
450    const asking = ASKING.has(e.tool)
451    if (asking) isAsking = true
452    const input = e as unknown as Record<string, unknown>
453    if (['Edit', 'Write', 'NotebookEdit'].includes(e.tool)) {
454      edited.add(String(input.file_path ?? input.notebook_path ?? ''))
455    }
456    await report($, asking ? 'waiting' : 'running', asking ? '等你回答' : describe(e.tool, input))
457    if (!settle) await moodTo($, (asking ? 'waiting' : LOOKING.has(e.tool) ? 'review' : 'running'))
458
459    const ran = await next(e)
460
461    if (asking) {
462      isAsking = false
463      await report($, 'running')
464    }
465    if (ran.deny === undefined && ran.isError === true) {
466      await flash($, 'failed', 2000)
467    } else if (!settle) {
468      await moodTo($, base())
469    }
470    return ran
471  })
472
473  on('classic.PermissionRequest', async ($, e, next) => {
474    isAsking = true
475    const doing = activity.action
476    await report($, 'waiting', `等你核准 ${e.tool_name}`)
477    await setMood($, 'waiting')
478    const answer = await next(e)
479    isAsking = false
480    await report($, 'running', doing)
481    await moodTo($, base())
482    return answer
483  })
484
485  on('turn.complete', async ($, e, next) => {
486    inTurn = false
487    isAsking = false
488    const said = e.answer.trim().split('\n').find(line => line.trim())?.replace(/[*#`>]/g, '').trim() ?? ''
489    if (e.reason === 'answer') {
490      await report($, 'ready', said.slice(0, 60) || (edited.size ? `編輯了 ${edited.size} 個檔案` : '完成'))
491    } else if (e.reason === 'aborted') {
492      await report($, 'idle', '')
493    } else {
494      await report($, 'blocked', e.reason === 'refusal' ? '拒絕了這個要求' : 'API 錯誤')
495    }
496    if (e.reason === 'answer') await flash($, 'jumping', 2500)
497    else if (e.reason === 'aborted') await setMood($, 'idle')
498    else await flash($, 'failed', 4000)
499    return next(e)
500  })
501
502  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
503    if (await read($, isHidden)) return next(e)
504    const current = await read($, pet)
505    const failed = await read($, error)
506    await read($, bakedAt)
507    const now = await read($, mood)
508    const { Box, Text } = $.ui.resolve(e)
509
510    if (!current) {
511      return failed ? (
512        <Box>
513          <Text dimColor>codex-pet: {failed}</Text>
514        </Box>
515      ) : (
516        next(e)
517      )
518    }
519
520    const label = `${current.displayName} · ${LABELS[now]}`
521
522    if (e.surface === 'desktop') {
523      const { Svg } = $.ui.resolve(e)
524      const strip = currentStrip(now)
525      const at = await read($, frame)
526      const href = strip?.frames[at % strip.frames.length]
527      return (
528        <Box flexDirection="row" alignItems="flex-end">
529          {strip && href && (
530            <Svg source={frameSvg(href, strip.w, strip.h)} alt={label} width={66} height={72} />
531          )}
532          <Text dimColor> {label}</Text>
533        </Box>
534      )
535    }
536
537    return (
538      <Box>
539        <Text dimColor>🐾 {label}</Text>
540      </Box>
541    )
542  })
543}
544
types/index.d.ts 29 lines
1export type Mood =
2  | 'idle'
3  | 'running'
4  | 'review'
5  | 'waiting'
6  | 'failed'
7  | 'jumping'
8  | 'waving'
9
10/** A session's status on the desktop pet, as the Codex pet names them. */
11export type Status = 'idle' | 'running' | 'waiting' | 'blocked' | 'ready'
12
13export type PetInfo = { id: string; displayName: string; followsCodex: boolean }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'codex-pet': {
18      mood: Mood
19      pet: PetInfo | null
20      /** Bumped when the module's sprite cache is (re)filled, so the band redraws. */
21      bakedAt: number
22      error: string | null
23      isHidden: boolean
24      /** Index into the current mood's frames; the mod's timer advances it. */
25      frame: number
26    }
27  }
28}
29