Un compagnon ASCII qui vit au-dessus du prompt et réagit à la session

Un compagnon ASCII pour Claude Code : il vit au-dessus du prompt, gigote, réagit à la session (erreurs, tours longs, modifications), te répond quand tu l'appelles par son nom, et peut être libéré pour faire éclore un nouvel œuf. 8 espèces, 5 raretés, 1 % de shiny.
Dans une session Claude Code (terminal) :
/plugin install buddy --marketplace bharismendy/claude-buddy
Réponds y pour ajouter le marketplace, puis choisis la portée (user par défaut).
/buddy sa carte et ses stats
/buddy <message> lui parler (ou écris son nom dans un prompt)
/buddy pet le caresser (ou ♥ / ctrl+x tab puis p)
/buddy mute | unmute couper / rendre ses bulles spontanées
/buddy hide | show le cacher / le faire revenir
/buddy rename <nom> le renommer
/buddy release <nom> le libérer pour de bon (laisse un œuf)
/buddy hatch faire éclore l'œuf
/buddy dex les espèces possibles et les raretés
/buddy help l'aide
Les réponses quand on lui parle passent par Haiku ; ses autres réactions sont des phrases toutes faites.
hooks/register.tsx 473 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register } from 'claude-code'
3
4import type { Pet, Rarity, Released, StatName } from '../types'
5
6const pet = atom({ plugin: 'buddy', key: 'pet' } as const, null)
7const tick = atom({ plugin: 'buddy', key: 'tick' } as const, 0)
8const bubble = atom({ plugin: 'buddy', key: 'bubble' } as const, null)
9const heartsUntil = atom({ plugin: 'buddy', key: 'heartsUntil' } as const, 0)
10const isHidden = atom({ plugin: 'buddy', key: 'isHidden' } as const, false)
11const isEgg = atom({ plugin: 'buddy', key: 'isEgg' } as const, false)
12const hatchingUntil = atom({ plugin: 'buddy', key: 'hatchingUntil' } as const, 0)
13const isMuted = atom({ plugin: 'buddy', key: 'isMuted' } as const, false)
14// Message à qui il doit répondre ; traité par la boucle du ticker, qui survit aux dispatchs.
15const ask = atom({ plugin: 'buddy', key: 'ask' } as const, null)
16
17const TICK_MS = 500
18const BUBBLE_TICKS = 14
19const HEART_TICKS = 6
20const CHATTER_COOLDOWN_TICKS = 40
21const HATCH_TICKS = 8
22const IDLE_TICKS = 1200
23const SUBCOMMANDS = ['help', 'dex', 'card', 'pet', 'hide', 'show', 'mute', 'unmute', 'rename', 'release', 'hatch', 'talk']
24
25const HELP = [
26 '/buddy sa carte et ses stats',
27 '/buddy <message> lui parler (ou écris son nom dans un prompt)',
28 '/buddy pet le caresser (ou ♥ / ctrl+x tab puis p)',
29 '/buddy mute | unmute couper / rendre ses bulles spontanées',
30 '/buddy hide | show le cacher / le faire revenir',
31 '/buddy rename <nom> le renommer',
32 '/buddy release <nom> le libérer pour de bon (laisse un œuf)',
33 '/buddy hatch faire éclore l’œuf',
34 '/buddy dex les espèces possibles et les raretés',
35 '/buddy help cette aide',
36].join('\n')
37
38const EGG = {
39 idle: [' .--. ', ' / \ ', ' | | ', ' \____/ '],
40 wobble: [' .--. ', ' / \ ', '| | ', ' \____/ '],
41 // L'œuf se fissure sur HATCH_TICKS ticks, une frame par quart.
42 cracks: [
43 [' .--. ', ' / \ ', ' | /\ | ', ' \____/ '],
44 [' .--. ', ' / / \ ', ' | \/\ | ', ' \____/ '],
45 [' .,-. ', ' /\/\/\ ', ' | \/ \| ', ' \____/ '],
46 [' _ _ ', ' /\/\/\ ', ' | | ', ' \____/ '],
47 ],
48}
49
50// E = l'œil, remplacé au rendu ; deux frames par espèce, la seconde sert à gigoter.
51const SPECIES: Record<string, [string[], string[]]> = {
52 canard: [
53 [' __ ', ' <(E )___', ' ( ._> ', ' `--´ '],
54 [' __ ', ' <(E )___', ' ( ._> ', ' `--´~ '],
55 ],
56 chat: [
57 [' /\\_/\\ ', '( E.E ) ', ' > ^ < ', ' (_ _) '],
58 [' /\\_/\\ ', '( E.E ) ', ' > ^ < ', ' (_ _)~ '],
59 ],
60 blob: [
61 [' .--. ', ' ( E E ) ', ' ( ‿ ) ', ' `--´ '],
62 [' .----. ', '( E E ) ', '( ‿ ) ', ' `----´ '],
63 ],
64 hibou: [
65 [' ,___, ', ' {E,E} ', ' /)__) ', ' -"-"- '],
66 [' ,___, ', ' {E.E} ', ' /)__) ', ' -"-"- '],
67 ],
68 fantôme: [
69 [' .-. ', ' (E E) ', ' | O | ', " '~~~' "],
70 [' .-. ', ' (E E) ', ' | o | ', " '~'~' "],
71 ],
72 axolotl: [
73 ['}\\(E‿E)/{', ' ( ) ', ' U U ', ' '],
74 ['}/(E‿E)\\{', ' ( ) ', ' U U ', ' '],
75 ],
76 robot: [
77 [' [###] ', ' [E_E] ', ' /|=|\\ ', ' d b '],
78 [' [#*#] ', ' [E_E] ', ' \\|=|/ ', ' b d '],
79 ],
80 dragon: [
81 [' /\\_/\\_ ', ' ( E E )>', ' / vv \\ ', ' ~(____) '],
82 [' /\\_/\\_ ', ' ( E E )>', ' / ^^ \\ ', ' (____)~ '],
83 ],
84}
85
86const EYES = ['·', 'o', '°', '^', '✦', '@', '•']
87const NAMES = ['Pixel', 'Grumly', 'Biscotte', 'Nougat', 'Kernel', 'Mochi', 'Tofu', 'Segfault', 'Patate', 'Lambda', 'Brioche', 'Zéphyr']
88const STATS: StatName[] = ['DEBUG', 'PATIENCE', 'CHAOS', 'SAGESSE', 'SARCASME']
89const RARITIES: [Rarity, number, number][] = [
90 // [rareté, poids, plancher des stats]
91 ['commun', 60, 1],
92 ['peu commun', 25, 15],
93 ['rare', 10, 30],
94 ['épique', 4, 45],
95 ['légendaire', 1, 60],
96]
97const RARITY_COLOR: Record<Rarity, Color> = {
98 commun: 'inactive',
99 'peu commun': 'success',
100 rare: 'suggestion',
101 épique: 'permission',
102 légendaire: 'warning',
103}
104
105const QUIPS = {
106 submit: ['Allez, on s’y met !', 'Je t’écoute.', 'Hmm, intéressant…', 'C’est parti !'],
107 error: ['Aïe. Ça a planté.', 'Rouge partout…', 'On respire. On relit la stack.', 'Classique.'],
108 edit: ['Joli diff.', 'Ça compile ? 👀', 'Propre.', 'Hop, un fichier de plus.'],
109 done: ['Fini !', 'Et voilà.', 'Mission accomplie.', 'Pas mal du tout.'],
110 long: ['Ouf, c’était long.', 'J’ai failli m’endormir.', 'Ça valait le coup d’attendre ?'],
111 aborted: ['Interrompu…', 'On change de plan ?', 'Ok, ok, j’arrête.'],
112 called: ['Oui ?', 'Présent !', 'Tu m’as appelé ?', 'Je suis là !'],
113 pet: ['♥', 'Rrrr…', 'Encore !', 'Merci ♥'],
114 idle: ['Tu es toujours là ?', 'Pause café ?', 'Je m’ennuie un peu…', 'On reprend quand tu veux.'],
115 farewell: ['Adieu… 👋', 'Merci pour tout !', 'Je m’envole !', 'Prends soin du code.'],
116}
117
118const pick = <T,>(list: readonly T[]): T => list[Math.floor(Math.random() * list.length)] as T
119
120function hatch(now: number): Pet {
121 const roll = Math.random() * 100
122 let acc = 0
123 let rarity: Rarity = 'commun'
124 let floor = 1
125 for (const [name, weight, min] of RARITIES) {
126 acc += weight
127 if (roll < acc) {
128 rarity = name
129 floor = min
130 break
131 }
132 }
133 const stats = Object.fromEntries(
134 STATS.map(stat => [stat, floor + Math.floor(Math.random() * (101 - floor))]),
135 ) as Record<StatName, number>
136
137 return {
138 species: pick(Object.keys(SPECIES)),
139 name: pick(NAMES),
140 rarity,
141 eye: pick(EYES),
142 isShiny: Math.random() < 0.01,
143 stats,
144 hatchedAt: now,
145 }
146}
147
148// Les espèces en grille de 4, celles déjà rencontrées cochées, puis les chances par rareté.
149function dex(seen: Set<string>): string {
150 const COL = 14
151 const names = Object.keys(SPECIES)
152 const rows: string[] = []
153 for (let i = 0; i < names.length; i += 4) {
154 const group = names.slice(i, i + 4)
155 const sprites = group.map(name => SPECIES[name]![0].map(line => line.replaceAll('E', 'o')))
156 for (let l = 0; l < 4; l++) rows.push(sprites.map(sp => (sp[l] ?? '').padEnd(COL)).join('').trimEnd())
157 rows.push(group.map(name => `${seen.has(name) ? '✓' : '·'} ${name}`.padEnd(COL)).join('').trimEnd(), '')
158 }
159 const odds = RARITIES.map(([rarity, weight]) => `${rarity} ${weight} %`).join(' · ')
160
161 return [...rows, `Raretés : ${odds} · shiny 1 %`, `${seen.size}/${names.length} espèces rencontrées`].join('\n')
162}
163
164function memorial(released: Released[]): string[] {
165 if (released.length === 0) return []
166
167 return ['', 'Anciens compagnons :', ...released.slice(-5).map(r => ` ${r.name} — ${r.species} ${r.rarity}`)]
168}
169
170function card(p: Pet): string {
171 const bar = (n: number) => '█'.repeat(Math.round(n / 10)).padEnd(10, '░')
172 const sprite = SPECIES[p.species]?.[0] ?? []
173
174 return [
175 ...sprite.map(line => ' ' + line.replaceAll('E', p.eye)),
176 '',
177 `${p.isShiny ? '✨ ' : ''}${p.name} — ${p.species} ${p.rarity}`,
178 `Éclos le ${new Date(p.hatchedAt).toLocaleDateString('fr-FR')}`,
179 '',
180 ...STATS.map(stat => `${stat.padEnd(9)} ${bar(p.stats[stat])} ${p.stats[stat]}`),
181 ].join('\n')
182}
183
184let lastChatter = -CHATTER_COOLDOWN_TICKS
185let lastActivity = 0
186let isReplying = false
187
188// chatter : bavardage limité par le cooldown ; event : réaction à la session ; direct : réponse à une demande explicite.
189// Le mute coupe chatter et event, jamais direct.
190async function say($: EngineInterface, text: string, mode: 'chatter' | 'event' | 'direct' = 'event') {
191 const now = await read($, tick)
192 if (mode !== 'direct' && (await read($, isMuted))) return
193 if (mode === 'chatter' && now - lastChatter < CHATTER_COOLDOWN_TICKS) return
194 lastChatter = now
195 await update($, bubble, () => ({ text, until: now + BUBBLE_TICKS }))
196}
197
198function greeting(hour: number): string {
199 if (hour < 6) return 'Tu codes encore à cette heure ?'
200 if (hour < 12) return 'Bonjour ! ☀'
201 if (hour < 18) return 'Re ! On s’y remet ?'
202 return 'Bonsoir ! Session du soir ?'
203}
204
205async function reply($: EngineInterface, p: Pet, message: string): Promise<string> {
206 const traits = STATS.map(stat => `${stat} ${p.stats[stat]}/100`).join(', ')
207 // Le moteur rejette une requête qu'il refuse d'envoyer (modèle bloqué…) : il répond alors une phrase toute faite.
208 const r = await $.model.complete({
209 model: 'haiku',
210 system:
211 `Tu es ${p.name}, un petit ${p.species} en ASCII (${p.rarity}) qui vit au-dessus du prompt ` +
212 `du terminal de ton humain, un développeur. Traits : ${traits} ; plus un trait est haut, plus il colore ` +
213 `ta réponse. Réponds en français, en une seule phrase de 20 mots maximum, sans guillemets ni markdown.`,
214 prompt: message.slice(0, 1000),
215 maxTokens: 80,
216 timeoutMs: 8000,
217 }).catch(() => null)
218
219 return r?.isAnswered ? r.text.trim().split('\n')[0]!.slice(0, 160) : pick(QUIPS.called)
220}
221
222// Appelé par le ticker : répond au message en attente, un à la fois.
223async function answerPending($: EngineInterface) {
224 const message = await read($, ask)
225 const current = await read($, pet)
226 if (message === null || current === null || isReplying) return
227 isReplying = true
228 try {
229 await update($, ask, () => null)
230 if (await read($, isMuted)) return
231 await say($, '…', 'direct')
232 await say($, await reply($, current, message), 'direct')
233 } finally {
234 isReplying = false
235 }
236}
237
238async function onTick($: EngineInterface) {
239 if (await read($, isHidden)) return
240 const now = await read($, tick)
241 await update($, tick, n => n + 1)
242 if (now - lastActivity === IDLE_TICKS) await say($, pick(QUIPS.idle), 'chatter')
243 await answerPending($)
244}
245
246async function caress($: EngineInterface) {
247 const now = await read($, tick)
248 await update($, heartsUntil, () => now + HEART_TICKS)
249 await say($, pick(QUIPS.pet), 'direct')
250}
251
252export const register: Register = on => {
253 on('session.start', async ($, e, next) => {
254 // undefined : premier lancement, on fait éclore ; null : relâché, un œuf attend.
255 let stored = (await $.store.get('pet')) as Pet | null | undefined
256 if (stored === undefined) {
257 stored = hatch(await $.clock.now())
258 await $.store.set('pet', stored)
259 }
260 const hatched = stored
261 await update($, pet, () => hatched)
262 await update($, isEgg, () => hatched === null)
263 const muted = (await $.store.get('muted')) === true
264 await update($, isMuted, () => muted)
265 if (hatched !== null) await say($, greeting(new Date(await $.clock.now()).getHours()))
266 await $.command.register({
267 name: 'buddy',
268 description: 'Ton compagnon (/buddy help pour les commandes)',
269 })
270 $.clock.every(TICK_MS, () => void onTick($))
271
272 return next(e)
273 })
274
275 on('command.run', { command: 'buddy' }, async ($, e) => {
276 const words = e.args.trim().split(/\s+/).filter(Boolean)
277 // /buddy <texte libre> = lui parler.
278 const [sub = 'card', ...rest] = words[0] !== undefined && !SUBCOMMANDS.includes(words[0]) ? ['talk', ...words] : words
279 if (sub === 'help') return { text: HELP }
280 const released = ((await $.store.get('released')) ?? []) as Released[]
281 if (sub === 'dex') {
282 const owned = await read($, pet)
283 const seen = new Set([...released.map(r => r.species), ...(owned === null ? [] : [owned.species])])
284 return { text: dex(seen) }
285 }
286
287 if (sub === 'hatch') {
288 if (!(await read($, isEgg))) return { text: 'Il n’y a pas d’œuf à faire éclore. /buddy release d’abord.' }
289 const now = await read($, tick)
290 const fresh = hatch(await $.clock.now())
291 await $.store.set('pet', fresh)
292 await update($, isHidden, () => false)
293 await update($, hatchingUntil, () => now + HATCH_TICKS)
294 await update($, bubble, () => ({ text: 'Bonjour le monde !', until: now + HATCH_TICKS + BUBBLE_TICKS }))
295 await update($, pet, () => fresh)
296 await update($, isEgg, () => false)
297 return { text: `L’œuf éclot…\n\n${card(fresh)}` }
298 }
299
300 const current = await read($, pet)
301 if (current === null) {
302 const egg = 'Un œuf attend au-dessus du prompt. /buddy hatch pour le faire éclore.'
303 return { text: [egg, ...memorial(released)].join('\n') }
304 }
305
306 switch (sub) {
307 case 'pet':
308 await update($, isHidden, () => false)
309 await caress($)
310 return { text: `Tu caresses ${current.name}. ♥` }
311 case 'talk': {
312 const message = rest.join(' ')
313 if (message === '') return { text: 'Usage : /buddy <message>' }
314 const answer = await reply($, current, message)
315 await update($, isHidden, () => false)
316 await say($, answer, 'direct')
317 return { text: `${current.name} : ${answer}` }
318 }
319 case 'mute':
320 await update($, isMuted, () => true)
321 await $.store.set('muted', true)
322 return { text: `${current.name} se tait. /buddy unmute pour lui rendre la parole.` }
323 case 'unmute':
324 await update($, isMuted, () => false)
325 await $.store.set('muted', false)
326 await say($, 'Enfin ! J’avais des choses à dire.', 'direct')
327 return { text: `${current.name} peut de nouveau parler.` }
328 case 'hide':
329 await update($, isHidden, () => true)
330 return { text: `${current.name} fait une sieste. /buddy show pour le réveiller.` }
331 case 'show':
332 await update($, isHidden, () => false)
333 await say($, 'Coucou !', 'direct')
334 return { text: `${current.name} est de retour.` }
335 case 'rename': {
336 const name = rest.join(' ').slice(0, 20)
337 if (name === '') return { text: 'Usage : /buddy rename <nom>' }
338 const renamed = { ...current, name }
339 await update($, pet, () => renamed)
340 await $.store.set('pet', renamed)
341 await say($, `Je m’appelle ${name} maintenant !`, 'direct')
342 return { text: `Renommé en ${name}.` }
343 }
344 case 'release': {
345 if (rest.join(' ').toLowerCase() !== current.name.toLowerCase()) {
346 return { text: `${current.name} partira pour de bon. Tape /buddy release ${current.name} pour confirmer.` }
347 }
348 const gone: Released = {
349 name: current.name,
350 species: current.species,
351 rarity: current.rarity,
352 releasedAt: await $.clock.now(),
353 }
354 await $.store.set('released', [...released, gone].slice(-20))
355 await $.store.set('pet', null)
356 await update($, isHidden, () => false)
357 await update($, pet, () => null)
358 await update($, isEgg, () => true)
359 await say($, `${current.name} : ${pick(QUIPS.farewell)}`, 'direct')
360 return { text: `${current.name} s’est envolé. Un œuf est apparu : /buddy hatch pour le faire éclore.` }
361 }
362 default:
363 return { text: [card(current), ...memorial(released)].join('\n') }
364 }
365 })
366
367 on('prompt.submit', async ($, e, next) => {
368 lastActivity = await read($, tick)
369 const current = await read($, pet)
370 if (current !== null && e.text.toLowerCase().includes(current.name.toLowerCase())) {
371 await update($, ask, () => e.text)
372 } else if (Math.random() < 0.3) {
373 await say($, pick(QUIPS.submit), 'chatter')
374 }
375
376 return next(e)
377 })
378
379 on('tool.call', async ($, e, next) => {
380 const ran = await next(e)
381 if (e.agentId !== undefined) return ran
382
383 if ('isError' in ran && ran.isError === true) {
384 await say($, pick(QUIPS.error))
385 } else if ((e.tool === 'Edit' || e.tool === 'Write') && Math.random() < 0.25) {
386 await say($, pick(QUIPS.edit), 'chatter')
387 }
388
389 return ran
390 })
391
392 on('turn.complete', async ($, e, next) => {
393 if (e.agentId === undefined) {
394 lastActivity = await read($, tick)
395 if (e.isAborted) await say($, pick(QUIPS.aborted))
396 else if (e.durationMs > 120_000) await say($, pick(QUIPS.long))
397 else if (Math.random() < 0.3) await say($, pick(QUIPS.done), 'chatter')
398 }
399
400 return next(e)
401 })
402
403 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
404 const current = await read($, pet)
405 const egg = await read($, isEgg)
406 if (e.props.hasSurvey || (current === null && !egg) || (await read($, isHidden))) {
407 return next(e)
408 }
409
410 const { Box, Button, Text } = $.ui.resolve(e)
411 const now = await read($, tick)
412 const said = await read($, bubble)
413 const showBubble = said !== null && said.until > now && e.props.bodyColumns >= 40
414 const left = await read($, hatchingUntil)
415
416 if (current === null || left > now) {
417 const step = HATCH_TICKS - (left - now)
418 const sprite = current === null
419 ? (now % 6 === 5 ? EGG.wobble : EGG.idle)
420 : (EGG.cracks[Math.min(EGG.cracks.length - 1, Math.floor((step * EGG.cracks.length) / HATCH_TICKS))] ?? EGG.idle)
421
422 return (
423 <Box flexDirection="row" justifyContent="flex-end" alignItems="flex-end" width={e.props.bodyColumns}>
424 {showBubble && (
425 <Box borderStyle="round" borderColor="subtle" paddingX={1} marginRight={1} marginBottom={1}>
426 <Text wrap="wrap">{said.text}</Text>
427 </Box>
428 )}
429 <Box flexDirection="column" alignItems="center">
430 {sprite.map(line => (
431 <Text>{line}</Text>
432 ))}
433 <Text dimColor>{current === null ? '/buddy hatch' : 'crac…'}</Text>
434 </Box>
435 </Box>
436 )
437 }
438
439 const hearts = (await read($, heartsUntil)) > now
440 const frames = SPECIES[current.species] ?? SPECIES.blob!
441 // Gigote vite pendant un tour, lentement au repos ; cligne de temps en temps.
442 const isFidget = e.props.isWorking ? now % 2 === 1 : now % 8 === 7
443 const eye = now % 13 === 0 ? '-' : current.eye
444 const sprite = frames[isFidget ? 1 : 0].map(line => line.replaceAll('E', eye))
445 const color = RARITY_COLOR[current.rarity]
446 const muted = await read($, isMuted)
447
448 return (
449 <Box flexDirection="row" justifyContent="flex-end" alignItems="flex-end" width={e.props.bodyColumns}>
450 {showBubble && (
451 <Box borderStyle="round" borderColor={color} paddingX={1} marginRight={1} marginBottom={1}>
452 <Text wrap="wrap">{said.text}</Text>
453 </Box>
454 )}
455 <Box flexDirection="column" alignItems="center">
456 <Text color="error">{hearts ? (now % 2 ? ' ♥ ♥ ' : ' ♥ ') : ' '}</Text>
457 {sprite.map(line => (
458 <Text color={current.isShiny ? 'warning' : color}>{line}</Text>
459 ))}
460 <Box flexDirection="row">
461 <Text bold color={color}>
462 {current.isShiny ? '✨' : ''}
463 {current.name}{' '}
464 </Text>
465 {muted && <Text dimColor>🔇 </Text>}
466 <Button key="pet" label="♥" plain hotkey="p" onPress={() => caress($)} />
467 </Box>
468 </Box>
469 </Box>
470 )
471 })
472}
473types/index.d.ts 33 lines1export type Rarity = 'commun' | 'peu commun' | 'rare' | 'épique' | 'légendaire'
2export type StatName = 'DEBUG' | 'PATIENCE' | 'CHAOS' | 'SAGESSE' | 'SARCASME'
3
4export type Pet = {
5 species: string
6 name: string
7 rarity: Rarity
8 eye: string
9 isShiny: boolean
10 stats: Record<StatName, number>
11 hatchedAt: number
12}
13
14export type Released = { name: string; species: string; rarity: Rarity; releasedAt: number }
15
16export type Bubble = { text: string; until: number }
17
18declare module 'claude-code' {
19 interface PluginState {
20 buddy: {
21 pet: Pet | null
22 tick: number
23 bubble: Bubble | null
24 heartsUntil: number
25 isHidden: boolean
26 isEgg: boolean
27 hatchingUntil: number
28 isMuted: boolean
29 ask: string | null
30 }
31 }
32}
33