Claude の出力トークンを餌に育つ魚。1日でどこまで育てられるか

$ pwd
# -> /Users/hoge
$ git clone git@github.com:tocomi/dotfiles.git
$ cd dotfiles
$ ./setup.shhooks/register.tsx 189 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Day, Tank } from '../types'
5
6const EMPTY: Tank = { day: '', food: 0, level: 0, fedAt: 0, best: null, history: [] }
7const tankAtom = atom({ plugin: 'tank', key: 'tank' } as const, EMPTY)
8
9const JST_OFFSET = 9 * 3_600_000
10const TICK_MS = 250
11// 魚が泳ぐ幅とゲージの幅(1行に収める)
12const TANK_WIDTH = 14
13const BAR_WIDTH = 10
14const HISTORY_MAX = 14
15// Lv.1 に必要な餌(出力トークン)。以降はレベルごとに倍
16const BASE_FOOD = 500
17
18// レベルごとの姿
19const STAGES: [number, string, string][] = [
20 [0, '🥚', 'たまご'],
21 [1, '🐟', 'さかな'],
22 [3, '🐠', 'ねったいぎょ'],
23 [5, '🐡', 'ふぐ'],
24 [7, '🐬', 'いるか'],
25 [9, '🦈', 'さめ'],
26 [11, '🐋', 'くじら'],
27]
28
29const C = { water: '#38bdf8', bubble: '#7dd3fc', food: '#fbbf24', label: '#9ca3af', level: '#facc15', bar: '#4ade80', track: '#374151' }
30
31function stage(level: number): { icon: string; name: string } {
32 let found = STAGES[0]!
33 for (const s of STAGES) if (level >= s[0]) found = s
34 return { icon: found[1], name: found[2] }
35}
36
37const levelOf = (food: number) => (food < BASE_FOOD ? 0 : Math.floor(Math.log2(food / BASE_FOOD)) + 1)
38const foodFor = (level: number) => (level === 0 ? 0 : BASE_FOOD * 2 ** (level - 1))
39
40function compact(n: number): string {
41 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
42 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
43 return String(n)
44}
45
46function today(now: number): string {
47 const d = new Date(now + JST_OFFSET)
48 return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
49}
50
51// 日付が変わっていたら昨日の分を記録に回してリセットする
52function rollover(t: Tank, day: string): Tank {
53 if (t.day === day) return t
54 if (t.day === '') return { ...t, day }
55 const done: Day = { day: t.day, food: t.food, level: t.level }
56 const isBest = t.best === null || done.level > t.best.level || (done.level === t.best.level && done.food > t.best.food)
57 return {
58 day,
59 food: 0,
60 level: 0,
61 fedAt: 0,
62 best: isBest ? done : t.best,
63 history: [...t.history, done].slice(-HISTORY_MAX),
64 }
65}
66
67async function feed($: EngineInterface, amount: number) {
68 const now = await $.clock.now()
69 const before = await read($, tankAtom)
70 // 同時に開いている他のセッションも餌をやっているので、手元の値ではなく保存された最新の値に足す
71 const saved = await $.store.get('tank')
72 const latest = saved !== undefined && typeof saved === 'object' ? (saved as Tank) : before
73 const base = rollover(latest, today(now))
74 const food = base.food + amount
75 const next: Tank = { ...base, food, level: levelOf(food), fedAt: now }
76 await $.store.set('tank', next)
77 await update($, tankAtom, () => next)
78 if (next.level > before.level && next.day === before.day) {
79 const s = stage(next.level)
80 $.ui.toast(`🎉 Lv.${next.level} ${s.icon} ${s.name}に成長!`)
81 }
82}
83
84export const register: Register = on => {
85 let working = false
86
87 on('session.start', async ($, e, next) => {
88 const result = await next(e)
89 await $.command.register({ name: 'tank', description: '水槽の様子と記録を見る' })
90 const saved = await $.store.get('tank')
91 const now = await $.clock.now()
92 const loaded = saved !== undefined && typeof saved === 'object' ? (saved as Tank) : EMPTY
93 await update($, tankAtom, () => rollover(loaded, today(now)))
94 // 作業中だけ、泳ぐアニメーションのために描き直す
95 $.clock.every(TICK_MS, () => {
96 if (working) $.ui.invalidate('ui.render')
97 })
98 return result
99 })
100
101 on('prompt.submit', async ($, e, next) => {
102 working = true
103 return next(e)
104 })
105
106 on('turn.complete', async ($, e, next) => {
107 if (e.agentId === undefined) working = false
108 return next(e)
109 })
110
111 // モデルが出力したトークンを餌にする(サブエージェントの分も)
112 on('turn.step', async function* ($, e, next) {
113 const result = yield* next(e)
114 const food = result.usage?.output_tokens ?? 0
115 if (food > 0) await feed($, food)
116 return result
117 })
118
119 on('command.run', { command: 'tank' }, async ($, e) => {
120 const t = await read($, tankAtom)
121 const s = stage(t.level)
122 const days = [...t.history.slice(-6), { day: t.day, food: t.food, level: t.level }]
123 const max = Math.max(1, ...days.map(d => d.level))
124 const lines = [
125 `${s.icon} 今日: Lv.${t.level} ${s.name} · 餌 ${compact(t.food)} トークン`,
126 t.best === null ? '最高記録: まだなし' : `最高記録: Lv.${t.best.level} ${stage(t.best.level).icon} (${t.best.day})`,
127 '',
128 ...days.map(d => `${d.day.slice(5)} ${'█'.repeat(Math.round((d.level / max) * 12)).padEnd(12, '░')} Lv.${d.level} ${stage(d.level).icon}`),
129 ]
130 return { text: lines.join('\n') }
131 })
132
133 // スピナーの下にレベルと水槽を1行で描く。動く水槽は右端に置いて、文字がずれないようにする
134 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
135 const spinner = await next(e)
136 const t = await read($, tankAtom)
137 const now = await $.clock.now()
138 const { Box, Text } = $.ui.resolve(e)
139
140 const tick = Math.floor(now / TICK_MS)
141 // 魚は水槽の端から端へ往復する(絵文字は2マス)
142 const span = TANK_WIDTH - 2
143 const pos = tick % (span * 2)
144 const x = pos < span ? pos : span * 2 - pos
145 const lane = Array.from({ length: TANK_WIDTH }, (_, i) => {
146 const phase = (tick + i * 7) % 23
147 return i % 5 === 2 && phase < 3 ? ['·', '°', 'o'][phase]! : ' '
148 })
149 const isEating = now - t.fedAt < 3000
150 const left = lane.slice(0, x).join('')
151 const right = lane.slice(x + 2).join('')
152 const s = stage(t.level)
153 const from = foodFor(t.level)
154 const to = foodFor(t.level + 1)
155 const progress = Math.min(BAR_WIDTH, Math.round(((t.food - from) / Math.max(1, to - from)) * BAR_WIDTH))
156
157 return (
158 <Box flexDirection="column">
159 {spinner}
160 {/* レベルとゲージは縮めず、幅が足りないときは右側の情報と水槽から切り詰める */}
161 <Box>
162 <Box flexShrink={0}>
163 <Text>
164 <Text color={C.level} bold>
165 Lv.{t.level}
166 </Text>
167 <Text color={C.label}> {s.name} </Text>
168 <Text backgroundColor={C.bar}>{' '.repeat(progress)}</Text>
169 <Text backgroundColor={C.track}>{' '.repeat(BAR_WIDTH - progress)}</Text>
170 </Text>
171 </Box>
172 <Text wrap="truncate-end">
173 <Text color={C.label}>
174 {' '}
175 {compact(t.food)}/{compact(to)}
176 </Text>
177 {t.best !== null && <Text color={C.label}> · 最高 Lv.{t.best.level}</Text>}
178 <Text color={C.water}> 🌊</Text>
179 <Text color={C.bubble}>{left}</Text>
180 <Text>{s.icon}</Text>
181 {isEating && <Text color={C.food}>·∴</Text>}
182 <Text color={C.bubble}>{isEating ? right.slice(2) : right}</Text>
183 </Text>
184 </Box>
185 </Box>
186 )
187 })
188}
189types/index.d.ts 11 lines1export type Day = { day: string; food: number; level: number }
2export type Tank = Day & { fedAt: number; best: Day | null; history: Day[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 tank: {
7 tank: Tank
8 }
9 }
10}
11