Clawd, the Claude pet, sits right above your prompt, on the right, with its laptop: it types while Claude works and falls asleep (Zzz) when you are idle.

Marketplace de plugins para Claude Code.
# 1. Agregar el marketplace
/plugin marketplace add Menendez2004/menendez-market
# 2. Instalar los plugins que quieras
/plugin install dev-orchestrator@menendez-market
/plugin install qa@menendez-market
/plugin install unit-test-generator@menendez-market
/plugin install clawd-pet@menendez-market
(Desde la terminal también funciona: claude plugin marketplace add Menendez2004/menendez-market.)
| Plugin | Descripción |
|---|---|
dev-orchestrator | Human-in-the-loop development orchestrator with a 2-level hierarchy: adopts the Lead's plan (or drafts one in plan mode under opusplan if none is given), launches one Task Agent per step in its own terminal (or inline for small steps) and its own git worktree, applying each finished step to the Lead's working tree as a checked, uncommitted patch, starting each step as soon as its dependencies finish, and each agent can use read-only research sub-agents (Sonnet 4.6) that prioritize graphify. Execution runs on the latest Sonnet. Never runs tests; runs checks once at the end. Escalates to the Lead on ambiguity or destructive actions. |
qa | Suite de QA (se instala completa). Incluye las skills: qa-orchestrator (orquesta sesiones de QA: lanza agentes de prueba en paralelo, recolecta resultados, hace triage de bugs y genera reportes), qa-happy-path (prueba los flujos principales de la UI con Playwright), qa-api-adversary (intenta romper la API: auth, validación, duplicados, condiciones de carrera, requests malformados), qa-debugger (aplica correcciones mínimas a los bugs reportados) y qa-personality-builder (crea personalidades de QA propias del proyecto en .qa/config.yml). |
clawd-pet | Mod (no es una skill): Clawd, la mascota de Claude, vive justo encima del prompt del CLI, a la derecha, con su laptop. Saluda cuando estás presente, teclea en su laptop mientras Claude trabaja, se pone casco en modo plan, queda noqueado cuando falla un comando y se duerme con un "Zzz" flotando cuando dejas de escribir (60 s por defecto; cámbialo con idleSeconds en /config). Escribe cualquier cosa para despertarlo; /pet lo oculta o lo muestra, y /pet color <nombre o #hex> (o /config) le cambia el color: orange, blue, green, purple, pink, red, yellow, gray o cualquier hexadecimal. |
unit-test-generator | Genera unit tests estrictos y aislados con el runner, la librería de mocks y las factories que ya usa el repo. Los datos salen de Faker o de factories (nunca objetos escritos a mano), con semilla fija, patrón AAA y casos de éxito, ramas, entradas inválidas, límites, errores y efectos secundarios. Exige al menos 90% de coverage (líneas, sentencias, ramas y funciones) en la unidad probada. Nunca modifica código de producción: reporta los bugs que encuentra. |
.claude-plugin/
marketplace.json # catálogo: cada plugin apunta a sus skills
plugins/
skills/
dev-orchestrator/ # SKILL.md + references/, rules/
qa/ # todas las skills de QA
qa-orchestrator/ # SKILL.md + references/, rules/, assets/
qa-happy-path/ # SKILL.md + rules/
qa-api-adversary/ # SKILL.md + rules/
qa-debugger/ # SKILL.md + rules/
qa-personality-builder/ # SKILL.md + references/, rules/
unit-test-generator/ # SKILL.md + references/, rules/
mods/
clawd-pet/ # .claude-plugin/plugin.json + hooks/register.tsx, types/, tests/
Cada entrada de marketplace.json usa "strict": false y declara sus skills con "skills": [...]. dev-orchestrator carga solo su skill (y su agente orch-researcher), unit-test-generator carga solo la suya, y el plugin qa carga las cinco skills de plugins/skills/qa/ juntas, porque qa-orchestrator lanza a las demás.
Para agregar una skill nueva: crea plugins/skills/<nombre>/SKILL.md y agrega una entrada en .claude-plugin/marketplace.json. Una skill de QA nueva va en plugins/skills/qa/<nombre>/ y se agrega a la lista skills del plugin qa. Valida con:
claude plugin validate .
Los mods (plugins de function hooks, como clawd-pet) viven en plugins/mods/<nombre>/ con su propio .claude-plugin/plugin.json, y su entrada en marketplace.json usa "source": "./plugins/mods/<nombre>". Cada cambio a un mod debe subir su version (en su plugin.json y en su entrada de marketplace.json): Claude Code guarda la copia instalada por versión, y si la versión no cambia /plugin update no la reemplaza. Para probarlos:
claude plugin validate plugins/mods/clawd-pet
claude plugin test plugins/mods/clawd-pet
claude --plugin-dir plugins/mods/clawd-pet # sesión de prueba con el mod cargadohooks/register.tsx 616 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PetMood } from '../types'
5
6const mood = atom({ plugin: 'clawd-pet', key: 'mood' } as const, 'awake')
7const frame = atom({ plugin: 'clawd-pet', key: 'frame' } as const, 0)
8const isHidden = atom({ plugin: 'clawd-pet', key: 'isHidden' } as const, false)
9const laptopStep = atom({ plugin: 'clawd-pet', key: 'laptopStep' } as const, 0)
10const isPlanMode = atom({ plugin: 'clawd-pet', key: 'isPlanMode' } as const, false)
11
12// How long Clawd stays knocked out after a command fails.
13const ERROR_MS = 3000
14
15// A quarter second a frame, so the typing looks quick; the laptop moves on
16// every other frame, so it still takes about two seconds to come out.
17const TICK_MS = 250
18
19// Where the laptop is, one step every other tick toward OPEN while Claude
20// works and back toward STOWED once it stops.
21const STOWED = 0 // Clawd faces you, the laptop behind its back, one corner peeking out
22const TURNED = 1 // Clawd turns sideways, facing left, and grabs the laptop
23const LIFTED = 2 // pulled out, held up closed in front of it
24const SET_DOWN = 3 // set down closed on the ground in front of it
25const OPEN = 4 // opened sideways, the screen tilted back; Clawd types
26
27// Fixed colors, so Clawd stays vivid whatever the terminal theme.
28const DEFAULT_COLOR = 'orange'
29const EYE_BLACK = '#000000'
30const LAPTOP_GREY = '#A8A8A8'
31const LAPTOP_DIM = '#5C5C5C'
32const SPARK_YELLOW = '#FFD166'
33const Z_FADED = '#7A5A4A'
34const SCREEN_GLOW = '#CFE3F7'
35const SCREEN_DIM = '#8FB4DA'
36const HAT_YELLOW = '#E8B04B'
37const HAT_BADGE = '#B07A1E'
38
39// The colors Clawd can be, by the name the person picks in /config or types
40// after `/pet color`; any #RRGGBB (or #RGB) works too.
41const PALETTE: Record<string, string> = {
42 orange: '#E8713A',
43 blue: '#4A90E2',
44 green: '#4CAF50',
45 purple: '#9B59B6',
46 pink: '#E86A9B',
47 red: '#E74C3C',
48 yellow: '#F1C40F',
49 gray: '#95A5A6',
50}
51
52// The hex a color name or hex code stands for, as #RRGGBB, or undefined when
53// it is neither a palette name nor a well-formed hex code.
54function toHex(color: string): string | undefined {
55 const value = color.trim().toLowerCase()
56 const named = PALETTE[value]
57 if (named !== undefined) {
58 return named
59 }
60 const short = /^#?([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(value)
61 if (short) {
62 return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
63 }
64 const long = /^#?([0-9a-f]{6})$/.exec(value)
65 return long ? `#${long[1]}` : undefined
66}
67
68// The same color at 60% brightness: shut eyes are drawn in it, so they read
69// as a darker line on any body color.
70function darken(hex: string): string {
71 const n = Number.parseInt(hex.slice(1), 16)
72 const channel = (shift: number) => Math.round(((n >> shift) & 0xff) * 0.6)
73 return `#${[16, 8, 0].map(shift => channel(shift).toString(16).padStart(2, '0')).join('')}`
74}
75
76// Clawd's body color and its darker shade, set from the person's choice when
77// the module loads (and again when they change it with /pet color).
78let BODY = PALETTE[DEFAULT_COLOR] ?? '#E8713A'
79let BODY_DARK = darken(BODY)
80
81function paint(hex: string) {
82 BODY = hex
83 BODY_DARK = darken(hex)
84}
85
86// One run of characters in a single style.
87type Seg = { text: string; color?: string; backgroundColor?: string; bold?: boolean }
88
89// Clawd as a 9×5 pixel sprite, two pixel rows per terminal row (half
90// blocks), so it is two and a half rows tall, close to the sticker's shape;
91// the head starts halfway down the top row:
92//
93// . # # # # # # # . row 0 head
94// # # E # # # E # # row 1 arms and eyes
95// # # # # # # # # # row 2 arms
96// . # # # # # # # . row 3 body
97// . # . # . # . # . row 4 legs
98//
99// An eye is one pixel: black when open (`▀`, black over orange), a dark
100// orange line when it blinks or while asleep. Facing you, Clawd only idles
101// and sleeps; asleep it lies still and only the Zs above it move. To work it
102// turns sideways (below).
103type Eyes = 'open' | 'blink' | 'asleep'
104
105function petRows(eyes: Eyes): Seg[][] {
106 const body = (text: string): Seg => ({ text, color: BODY, bold: true })
107 const eye: Seg =
108 eyes === 'open'
109 ? { text: '▀', color: EYE_BLACK, backgroundColor: BODY }
110 : { text: '▀', color: BODY_DARK, backgroundColor: BODY }
111
112 return [
113 [body(' ▄▄▄▄▄▄▄ ')],
114 [body('██'), eye, body('███'), eye, body('██')],
115 [body(' █▀█▀█▀█ ')],
116 ]
117}
118
119// Clawd in profile, facing left, as it works: the same 9×5 body with both
120// eyes toward the front (left) and one arm, the front one, reaching out to
121// the keyboard; the back arm is hidden behind its body.
122//
123// . # # # # # # # . row 0 head
124// . # E # # E # # . row 1 eyes
125// A # # # # # # # . row 2 front arm (at rest)
126// a # # # # # # # . row 3 front arm (on the keys)
127// . # . # . # . # . row 4 legs
128//
129// Typing, the front arm drops one pixel onto the keys and back up.
130function sidePetRows(isArmDown: boolean): Seg[][] {
131 const body = (text: string): Seg => ({ text, color: BODY, bold: true })
132 const eye: Seg = { text: '▀', color: EYE_BLACK, backgroundColor: BODY }
133
134 return [
135 [body(' ▄▄▄▄▄▄▄ ')],
136 [body(`${isArmDown ? ' ' : '▄'}█`), eye, body('██'), eye, body('██ ')],
137 [body(`${isArmDown ? '▀' : ' '}█▀█▀█▀█ `)],
138 ]
139}
140
141// The corner of the laptop peeking out from behind Clawd's back, one column
142// right of its head: dim while stowed, bright while Clawd turns and grabs it,
143// gone once out.
144function behindRows(step: number): Seg[][] {
145 const none: Seg[] = [{ text: ' ' }]
146 if (step === STOWED) {
147 return [[{ text: '▐', color: LAPTOP_DIM }], none, none]
148 }
149 if (step === TURNED) {
150 return [[{ text: '█', color: LAPTOP_GREY }], none, none]
151 }
152 return [none, none, none]
153}
154
155// The laptop, seen from the side like Clawd, five columns wide at every step
156// so Clawd never shifts: held up closed at arm height, set down closed, then
157// opened with the screen tilted back toward the left and the keyboard
158// running up to Clawd's front arm. While Clawd types the screen flickers.
159function laptopRows(step: number, isWorking: boolean, tick: number): Seg[][] {
160 const empty: Seg[] = [{ text: ' ' }]
161 const grey = (text: string): Seg => ({ text, color: LAPTOP_GREY, bold: true })
162
163 if (step === LIFTED) {
164 return [empty, [{ text: ' ' }, grey('▄▄▄')], empty]
165 }
166 if (step === SET_DOWN) {
167 return [empty, empty, [{ text: ' ' }, grey('▀▀▀')]]
168 }
169 if (step === OPEN) {
170 const screen: Seg = {
171 text: '╲',
172 color: isWorking && tick % 2 === 1 ? SCREEN_DIM : SCREEN_GLOW,
173 bold: true,
174 }
175 return [
176 [screen, { text: ' ' }],
177 [{ text: ' ' }, screen, { text: ' ' }],
178 [{ text: ' ' }, grey('▀▀▀')],
179 ]
180 }
181 return [empty, empty, empty]
182}
183
184// The hard hat Clawd wears while Claude is in plan mode: a yellow dome with
185// a darker badge in the row above its head, and the brim painted over the
186// empty top half of its head row, so it sits right on top of the head.
187function hatRow(): Seg[] {
188 const hat = (text: string, color = HAT_YELLOW): Seg => ({ text, color, bold: true })
189 return [{ text: ' ' }, hat('▄▄'), hat('▄', HAT_BADGE), hat('▄▄'), { text: ' ' }]
190}
191
192function brimRow(): Seg[] {
193 return [
194 { text: '▀', color: HAT_YELLOW, bold: true },
195 { text: '▀▀▀▀▀▀▀', color: HAT_YELLOW, backgroundColor: BODY, bold: true },
196 { text: '▀', color: HAT_YELLOW, bold: true },
197 ]
198}
199
200// Planning, Clawd faces you under its hard hat and marches in place: its
201// legs step between two stances every half second.
202function marchingLegs(tick: number): Seg[] {
203 const legs = Math.floor(tick / 2) % 2 === 0 ? ' █▀█▀█▀█ ' : ' ▀█▀█▀█▀ '
204 return [{ text: legs, color: BODY, bold: true }]
205}
206
207// Knocked out after a command fails: Clawd lies flat on its back, its legs
208// in the air, its eyes crossed out (×), with stars spinning over it and the
209// laptop tumbling off behind it.
210//
211// . . . . . . . . . legs up
212// . # . # . # . # .
213// # # X # # # X # # flat body
214// # # # # # # # # #
215function knockedOutRows(): Seg[][] {
216 const body = (text: string): Seg => ({ text, color: BODY, bold: true })
217 const eye: Seg = { text: '×', color: EYE_BLACK, backgroundColor: BODY, bold: true }
218 return [
219 [body(' ▄ ▄ ▄ ▄ ')],
220 [body('██'), eye, body('███'), eye, body('██')],
221 ]
222}
223
224const STARS = [' ✦ ✧ ', ' ✧ ✦ ', ' ✧ ✦ ', ' ✦ ✧ ']
225
226function starRow(tick: number): Seg[] {
227 const stars = STARS[Math.floor(tick / 2) % STARS.length] ?? ''
228 return [...stars].map(ch => (ch === ' ' ? { text: ch } : { text: ch, color: SPARK_YELLOW, bold: true }))
229}
230
231// The sparks that pop out of the laptop while Clawd types, one frame per
232// tick, in the row above the laptop and as wide as it.
233const SPARKS = [' ✻ ', ' · ✻ ', '✻ · ', ' ✻ ·']
234
235function sparkRow(tick: number): Seg[] {
236 const sparks = SPARKS[tick % SPARKS.length] ?? ''
237 return [...sparks].map(ch =>
238 ch === '✻' ? { text: ch, color: BODY, bold: true } : ch === '·' ? { text: ch, color: SPARK_YELLOW, bold: true } : { text: ch },
239 )
240}
241
242// The Zs that float up out of Clawd's head while it sleeps, in the row above
243// it: each is born small over the top of its head and drifts right one column
244// every half second, growing from `·` to `z` to `Z` and fading on its last
245// step. Two are in the air at once, three steps apart.
246const Z_SHAPES = ['·', 'z', 'z', 'Z', 'Z']
247const Z_CYCLE = 6
248
249function sleepRow(tick: number): Seg[] {
250 const cells: Seg[] = Array.from({ length: 9 }, () => ({ text: ' ' }))
251 const beat = Math.floor(tick / 2)
252 for (const offset of [0, 3]) {
253 const age = (beat + offset) % Z_CYCLE
254 const shape = Z_SHAPES[age]
255 if (shape !== undefined) {
256 cells[4 + age] = { text: shape, color: age === Z_SHAPES.length - 1 ? Z_FADED : BODY, bold: age >= 3 }
257 }
258 }
259 return cells
260}
261
262// Writes the mood only when it changed, so an idle tick redraws nothing new.
263async function setMood($: EngineInterface, next: PetMood) {
264 if ((await read($, mood)) !== next) {
265 await update($, mood, () => next)
266 }
267}
268
269// What moodNow needs to know beyond the plugin's state.
270type Clock = {
271 lastActivityAt: number
272 isTurnRunning: boolean
273 errorUntil: number
274 idleMs: number
275 // How many Plan subagents are running: Claude planning without plan mode.
276 planAgents: number
277 // What the footer under the prompt last said about plan mode, and what of
278 // it the plugin has already recorded (see the PromptHint hook).
279 footerSaysPlan: boolean
280 footerEverSaidPlan: boolean
281 footerApplied: boolean | undefined
282}
283
284// Clawd's mood right now: knocked out for a few seconds after a failed
285// command, planning or working while a turn runs, asleep once the person has
286// been idle long enough, awake otherwise.
287async function moodNow($: EngineInterface, c: Clock): Promise<PetMood> {
288 const now = await $.clock.now()
289 if (now < c.errorUntil) {
290 return 'error'
291 }
292 if (c.isTurnRunning) {
293 return c.planAgents > 0 || (await read($, isPlanMode)) ? 'planning' : 'working'
294 }
295 return now - c.lastActivityAt >= c.idleMs ? 'sleeping' : 'awake'
296}
297
298// Records whether plan mode is on. The engine's plan-mode notes switch it
299// both ways; the settings-hook events' `permission_mode` only ever switches it
300// on, so a field they leave stale cannot take the hat off mid-plan.
301async function notePermissionMode($: EngineInterface, permissionMode: string | undefined) {
302 if (permissionMode !== undefined && (await read($, isPlanMode)) !== (permissionMode === 'plan')) {
303 await update($, isPlanMode, () => permissionMode === 'plan')
304 }
305}
306
307export const register: Register = (on, options) => {
308 const idleMs = Math.max(5, Number(options.idleSeconds ?? 60)) * 1000
309 // A custom hex in /config wins over the palette pick; a malformed one is
310 // ignored and the pick is used.
311 paint(toHex(String(options.customColor ?? '')) ?? toHex(String(options.color ?? DEFAULT_COLOR)) ?? BODY)
312 const c: Clock = {
313 lastActivityAt: 0,
314 isTurnRunning: false,
315 errorUntil: 0,
316 idleMs,
317 planAgents: 0,
318 footerSaysPlan: false,
319 footerEverSaidPlan: false,
320 footerApplied: undefined,
321 }
322
323 on('session.start', async ($, e, next) => {
324 c.lastActivityAt = await $.clock.now()
325 await update($, mood, () => 'awake')
326
327 $.clock.every(TICK_MS, async () => {
328 // Shift+Tab: the footer is the first to show the mode changed. Its word
329 // is recorded once each time it changes; it only switches plan mode off
330 // once it has been seen saying it is on, so a footer that never shows
331 // the mode leaves the other signals in charge.
332 if (c.footerApplied !== c.footerSaysPlan && (c.footerSaysPlan || c.footerEverSaidPlan)) {
333 await notePermissionMode($, c.footerSaysPlan ? 'plan' : 'default')
334 c.footerApplied = c.footerSaysPlan
335 }
336 const current = await moodNow($, c)
337 await setMood($, current)
338 if ((await read($, frame)) % 2 === 0) {
339 // Working, the laptop comes out; knocked out, it stays where it was;
340 // otherwise (planning included) it goes back behind Clawd's back.
341 await update($, laptopStep, step =>
342 current === 'working'
343 ? Math.min(OPEN, (step ?? STOWED) + 1)
344 : current === 'error'
345 ? (step ?? STOWED)
346 : Math.max(STOWED, (step ?? STOWED) - 1),
347 )
348 }
349 await update($, frame, n => ((n ?? 0) + 1) % 1000)
350 })
351
352 // The pet keeps living even if the command cannot be registered.
353 try {
354 await $.command.register({
355 name: 'pet',
356 description: 'Show or hide Clawd, the Claude pet above the prompt; /pet color changes its color.',
357 argumentHint: '[color <name|#hex>]',
358 })
359 } catch {}
360
361 return next(e)
362 })
363
364 // `/pet` shows or hides Clawd; `/pet color` lists the colors; `/pet color
365 // <name|#hex>` repaints it now and saves the choice in the plugin's /config
366 // options (a name in "color", a hex in "customColor"), so it lasts.
367 on('command.run', { command: 'pet' }, async ($, e) => {
368 const [sub, ...rest] = e.args.trim().split(/\s+/)
369 if (sub !== 'color') {
370 const hidden = await update($, isHidden, h => !h)
371 return { text: hidden ? 'Clawd went home. Run /pet to bring it back.' : 'Clawd is back!' }
372 }
373
374 const names = Object.keys(PALETTE).join(', ')
375 const wanted = rest.join(' ')
376 if (wanted === '') {
377 return { text: `Clawd's colors: ${names}, or any hex like #00BCD4. Now: ${BODY}.` }
378 }
379
380 const hex = toHex(wanted)
381 if (hex === undefined) {
382 return { text: `"${wanted}" is not a color I know. Use one of: ${names}, or a hex like #00BCD4.` }
383 }
384
385 paint(hex)
386 const name = wanted.trim().toLowerCase()
387 const saves =
388 PALETTE[name] !== undefined
389 ? [
390 { key: 'clawd-pet.color', value: name },
391 { key: 'clawd-pet.customColor', value: '' },
392 ]
393 : [{ key: 'clawd-pet.customColor', value: hex }]
394 for (const save of saves) {
395 const { deny } = await $.config.set(save)
396 if (deny !== undefined) {
397 return { text: `Clawd is ${hex} for now, but the color could not be saved: ${deny}` }
398 }
399 }
400 return { text: `Clawd is now ${PALETTE[name] !== undefined ? name : hex}.` }
401 })
402
403 // Anything the person does in the prompt box wakes Clawd up.
404 on('prompt.edit', async ($, e, next) => {
405 c.lastActivityAt = await $.clock.now()
406 await setMood($, await moodNow($, c))
407 return next(e)
408 })
409
410 on('prompt.submit', async ($, e, next) => {
411 c.isTurnRunning = true
412 c.lastActivityAt = await $.clock.now()
413 await setMood($, await moodNow($, c))
414 return next(e)
415 })
416
417 on('turn.start', async ($, e, next) => {
418 c.isTurnRunning = true
419 c.lastActivityAt = await $.clock.now()
420 await setMood($, await moodNow($, c))
421 return next(e)
422 })
423
424 on('turn.complete', async ($, e, next) => {
425 c.isTurnRunning = false
426 c.lastActivityAt = await $.clock.now()
427 await setMood($, await moodNow($, c))
428 return next(e)
429 })
430
431 on('classic.UserPromptSubmit', async ($, e, next) => {
432 if (e.permission_mode === 'plan') {
433 await notePermissionMode($, 'plan')
434 }
435 return next(e)
436 })
437
438 on('classic.PostToolUse', async ($, e, next) => {
439 if (e.permission_mode === 'plan') {
440 await notePermissionMode($, 'plan')
441 }
442 return next(e)
443 })
444
445 // The engine's own record of plan mode: while it is on, every request of
446 // the main conversation carries a `plan_mode` reminder (and a
447 // `plan_mode_reentry` note when Claude goes back into it); when it ends, a
448 // `plan_mode_exit` note is made once. A subagent's rows are left out: only
449 // the main conversation's mode puts the hat on.
450 on('prompt.attachment', { type: 'plan_mode' }, async ($, e, next) => {
451 if (e.agentId === undefined) {
452 await notePermissionMode($, 'plan')
453 }
454 return next(e)
455 })
456
457 on('prompt.attachment', { type: 'plan_mode_reentry' }, async ($, e, next) => {
458 if (e.agentId === undefined) {
459 await notePermissionMode($, 'plan')
460 }
461 return next(e)
462 })
463
464 on('prompt.attachment', { type: 'plan_mode_exit' }, async ($, e, next) => {
465 if (e.agentId === undefined) {
466 await notePermissionMode($, 'default')
467 }
468 return next(e)
469 })
470
471 // Entering plan mode puts the hat on; a plan accepted takes it off.
472 on('tool.call', { tool: 'EnterPlanMode' }, async ($, e, next) => {
473 const result = await next(e)
474 if (!result.deny && !result.isError) {
475 await update($, isPlanMode, () => true)
476 }
477 return result
478 })
479
480 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
481 const result = await next(e)
482 if (!result.deny && !result.isError) {
483 await update($, isPlanMode, () => false)
484 }
485 return result
486 })
487
488 // Asked to plan, Claude may hand the work to its Plan subagent without
489 // entering plan mode: Clawd plans (hard hat on) while that agent runs.
490 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
491 if (e.subagent_type !== 'Plan') {
492 return next(e)
493 }
494 c.planAgents += 1
495 try {
496 return await next(e)
497 } finally {
498 c.planAgents -= 1
499 }
500 })
501
502 // The footer under the prompt says "plan mode on" while plan mode is on, the
503 // moment Shift+Tab switches it. A drawing never writes state, so this only
504 // notes what the line says; the timer records it.
505 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
506 c.footerSaysPlan = /plan mode on/i.test(e.props.hint)
507 c.footerEverSaidPlan = c.footerEverSaidPlan || c.footerSaysPlan
508 return next(e)
509 })
510
511 // A failing command (a build, a test run, a script) knocks Clawd out for a
512 // few seconds; an interrupt does not count.
513 on('classic.PostToolUseFailure', async ($, e, next) => {
514 if (e.permission_mode === 'plan') {
515 await notePermissionMode($, 'plan')
516 }
517 if (e.tool_name === 'Bash' && !e.is_interrupt) {
518 c.errorUntil = (await $.clock.now()) + ERROR_MS
519 await setMood($, 'error')
520 }
521 return next(e)
522 })
523
524 // Clawd sits in the band right above the prompt, against its right edge,
525 // on rows of its own so nothing squeezes it: four where the band has them
526 // (the top one for the sparks and the floating Zs), three otherwise. The
527 // row count never changes with the mood, so the prompt never jumps.
528 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
529 if (e.props.hasSurvey || e.props.maxRows < 3 || (await read($, isHidden))) {
530 return next(e)
531 }
532
533 const current = await read($, mood)
534 const tick = await read($, frame)
535 const step = await read($, laptopStep)
536 const { Box, Text } = $.ui.resolve(e)
537
538 const isWorking = current === 'working'
539 const isTyping = isWorking && step === OPEN
540 const isAsleep = current === 'sleeping'
541 const isPlanning = current === 'planning'
542 const isKnockedOut = current === 'error'
543 const wearsHat = !isAsleep && !isKnockedOut && (isPlanning || (await read($, isPlanMode)))
544 const eyes: Eyes = isAsleep ? 'asleep' : current === 'awake' && tick % 28 === 27 ? 'blink' : 'open'
545 // From the moment it turns to grab the laptop until it has put it back,
546 // Clawd is in profile; otherwise it faces you.
547 const isSideways = step > STOWED && !isKnockedOut
548 const bubble =
549 isKnockedOut
550 ? ['', '', '', 'oops!']
551 : isPlanning
552 ? ['', '', '', `planning${'.'.repeat((Math.floor(tick / 2) % 3) + 1)}`]
553 : isAsleep
554 ? ['', '', '', 'Zzz…']
555 : isTyping
556 ? ['', '', '', '']
557 : isWorking
558 ? ['', '', '', step <= TURNED ? 'hmm…' : 'got it!']
559 : step > STOWED
560 ? ['', '', '', 'done!']
561 : ['', '', '', 'hi!']
562 const blank: Seg[] = [{ text: ' ' }]
563 const laptop = [isTyping ? sparkRow(tick) : [{ text: ' ' }], ...laptopRows(step, isWorking, tick)]
564 const front = petRows(eyes)
565 const body: Seg[][] = isKnockedOut
566 ? [[{ text: ' ' }], ...knockedOutRows()]
567 : isSideways
568 ? sidePetRows(isTyping && tick % 2 === 1)
569 : isPlanning
570 ? [front[0] ?? blank, front[1] ?? blank, marchingLegs(tick)]
571 : front
572 const head = body[0] ?? blank
573 const pet = [
574 isKnockedOut ? starRow(tick) : isAsleep ? sleepRow(tick) : wearsHat ? hatRow() : blank,
575 wearsHat ? brimRow() : head,
576 ...body.slice(1),
577 ]
578 const tumblingLaptop: Seg[][] = [
579 [{ text: ' ' }],
580 [{ text: '▞', color: LAPTOP_GREY, bold: true }],
581 [{ text: '▀', color: LAPTOP_DIM }],
582 ]
583 const behind = [blank, ...(isKnockedOut ? tumblingLaptop : behindRows(step))]
584 const rows = e.props.maxRows >= 4 ? 4 : 3
585 const fit = <T,>(column: T[]): T[] => column.slice(column.length - rows)
586
587 const drawRow = (segs: Seg[], key: string) => (
588 <Text key={key}>
589 {segs.map((seg, i) => (
590 <Text key={`${key}-${i}`} color={seg.color} backgroundColor={seg.backgroundColor} bold={seg.bold}>
591 {seg.text}
592 </Text>
593 ))}
594 </Text>
595 )
596
597 // As wide as the band, everything pushed to its right end.
598 return (
599 <Box flexDirection="row" alignItems="flex-end" justifyContent="flex-end" width={e.props.bodyColumns}>
600 <Box flexDirection="column" alignItems="flex-end">
601 {fit(bubble).map((row, i) => (
602 <Text key={`bubble-${i}`} color={isAsleep ? Z_FADED : BODY} bold={!isAsleep}>
603 {row || ' '}
604 </Text>
605 ))}
606 </Box>
607 <Box flexDirection="column" marginLeft={1}>
608 {fit(laptop).map((r, i) => drawRow(r, `laptop-${i}`))}
609 </Box>
610 <Box flexDirection="column">{fit(pet).map((r, i) => drawRow(r, `pet-${i}`))}</Box>
611 <Box flexDirection="column">{fit(behind).map((r, i) => drawRow(r, `behind-${i}`))}</Box>
612 </Box>
613 )
614 })
615}
616types/index.d.ts 8 lines1export type PetMood = 'awake' | 'working' | 'planning' | 'error' | 'sleeping'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'clawd-pet': { mood: PetMood; frame: number; isHidden: boolean; laptopStep: number; isPlanMode: boolean }
6 }
7}
8