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

繁體中文 · 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 授權)。
這個 mod 不用自己的寵物格式,直接讀 Codex 的寵物資料夾 ${CODEX_HOME:-~/.codex}/pets/<id>/(pet.json + spritesheet.webp),所以:
/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 裡選它。
claude://code/continue?session=…,Codex 用 codex://threads/…。打開過的「完成」卡片會自動清掉。/pet-size 指令設定。/pet-mode):Win+Alt+O 顯示或隱藏桌寵(跟 Codex 的 Win+Alt+P 錯開)。py -3 -m pip install pillow),桌寵視窗用 tkinter 畫~/.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 會:
py)、tkinter 和 Pillow;缺 Pillow 時問你要不要用 pip 裝。~/.claude/settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS,讓每個 CC 對話都載入它。其他設定都會保留,修改前會先備份成 settings.json.bak-codex-pet。~/.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 的選擇 |
overlay | true | 開對話時要不要自動打開桌寵 |
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。
claude plugin validate .
claude plugin test .
py -3 -m unittest discover -s tests
最後一行跑桌寵物理和大小計算的測試(tests/test_physics.py)。
hooks/register.tsx 544 lines1import { 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}
544types/index.d.ts 29 lines1export 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