SLOPSHOPPER

pet

A small ASCII pet that lives in the band above the prompt, fed by /pet

newbandcommandtimer
v0.1.0no licenseupdated 2026-09-23seanmars/my-agent-plugins/plugins/pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 ⎿ pet: Mochi went to sleep. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pet

在輸入框正上方養一隻 ASCII 小寵物,用 /pet 呼叫。純裝飾,沒有任何需要照顧的狀態。

只支援 Claude Code. 使用 Claude Code 專屬的 function hooks, 安裝到其他 agent 不會運作.

                                                        /\_/\
                                                       ( o.o )
                                                        > ^ <
                                                       Mochi
> _

指令

指令作用
/pet顯示/隱藏
/pet show · /pet hide明確開關

顯示與否會存進 plugin 自己的 $.store,跨 session 保留.

行為

  • 每 700ms 一次心跳,每 8 次眨一次眼(約 5 秒).
  • Claude 跑 turn 的時候會冒點點.
  • 有 survey 佔用 band、或可用高度不足 4 行時自動讓位.

設定

/config 裡有兩列:

  • Pet name — 預設 Mochi
  • Show on start — session 啟動時是否直接顯示

需求

  • Claude Code ≥ 2.1.259
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
  • terminal surface(AbovePrompt 目前只有 terminal 有)

開發

claude --plugin-dir ./plugins/pet --debug   # 存檔即 hot-reload
claude plugin validate ./plugins/pet
claude plugin test ./plugins/pet

型別來自 /plugin-types 產生的 .claude/types/,更新 Claude Code 後要重跑.

Source 1 files
hooks/pet.tsx 116 lines
1/**
2 * pet — a small companion drawn in the band above the prompt.
3 *
4 * It has no state to keep but whether it is on screen: the pet blinks on the
5 * heartbeat and looks busy while a turn runs, and that is all it does.
6 *
7 * The art lives only in `sprite()`, so the terminal's `Raster` element can
8 * replace it later without touching anything else.
9 *
10 * Everything that takes `$` is a top-level declaration: the loader inventories
11 * what a hooks module reaches for, and refuses `$` passed into a closure it
12 * cannot see.
13 */
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16const STORE_KEY = 'visible'
17const TICK_MS = 700
18/** One tick of every eight is a blink, so the pet blinks about every 5 s. */
19const BLINK_EVERY = 8
20const COLOR = 'cyan'
21
22const EARS = '  /\\_/\\'
23const EYES_OPEN = ' ( o.o )'
24const EYES_SHUT = ' ( -.- )'
25const PAWS = '  > ^ <'
26
27let petName = 'Mochi'
28let showOnStart = true
29let visible = true
30let heartbeat: Timer | undefined
31let frame = 0
32
33/** Three lines of art, plus whatever floats beside the face this frame. */
34function sprite(isWorking: boolean): readonly string[] {
35  const blinking = frame % BLINK_EVERY === 0
36  const tag = isWorking ? ' .'.repeat((frame % 3) + 1) : ''
37  return [EARS, `${blinking ? EYES_SHUT : EYES_OPEN}${tag}`, PAWS]
38}
39
40function startHeartbeat($: EngineInterface): void {
41  if (heartbeat) return
42  heartbeat = $.clock.every(TICK_MS, () => {
43    frame += 1
44    $.ui.invalidate('ui.render')
45  })
46}
47
48function stopHeartbeat(): void {
49  heartbeat?.cancel()
50  heartbeat = undefined
51}
52
53async function setVisible($: EngineInterface, next: boolean): Promise<void> {
54  visible = next
55  if (visible) startHeartbeat($)
56  else stopHeartbeat()
57  await $.store.set(STORE_KEY, visible)
58  $.ui.invalidate('ui.render')
59}
60
61export const register: Register = (on, options) => {
62  petName = typeof options.petName === 'string' && options.petName.trim()
63    ? options.petName.trim()
64    : 'Mochi'
65  showOnStart = options.showOnStart !== false
66  visible = showOnStart
67
68  on('session.start', async ($, e, next) => {
69    const stored = await $.store.get(STORE_KEY)
70    visible = typeof stored === 'boolean' ? stored : showOnStart
71
72    await $.command.register({
73      name: 'pet',
74      description: `Show ${petName}, a small pet above the prompt.`,
75      argumentHint: '[show|hide]',
76    })
77
78    if (visible) startHeartbeat($)
79    return next(e)
80  })
81
82  on('command.run', { command: 'pet' }, async ($, e) => {
83    switch (e.args.trim().toLowerCase()) {
84      case '':
85        await setVisible($, !visible)
86        return { text: visible ? `${petName} is out.` : `${petName} went to sleep.` }
87      case 'show':
88        await setVisible($, true)
89        return { text: `${petName} is out.` }
90      case 'hide':
91        await setVisible($, false)
92        return { text: `${petName} went to sleep.` }
93      default:
94        return { text: 'Usage: /pet [show|hide]' }
95    }
96  })
97
98  on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
99    // A survey owns the band while it is up, and the drawing needs four rows.
100    if (!visible || e.props.hasSurvey || e.props.maxRows < 4) return next(e)
101
102    const { Box, Text } = $.ui.resolve(e)
103
104    return (
105      <Box width={e.props.bodyColumns} flexDirection="row" justifyContent="flex-end">
106        <Box flexDirection="column" alignItems="flex-start" marginRight={1}>
107          {sprite(e.props.isWorking).map((line) => (
108            <Text color={COLOR} wrap="truncate-end">{line}</Text>
109          ))}
110          <Text dimColor>{petName}</Text>
111        </Box>
112      </Box>
113    )
114  })
115}
116