SLOPSHOPPER

tank

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

newspinnercommandtoastprompttimer
v0.1.0no licenseupdated 2026-10-07tocomi/dotfiles/.claude/mods/tank
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tank
› 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 › /tank ⎿ tank: 🥚 今日: Lv.0 たまご · 餌 0 トークン ⎿ tank: 最高記録: まだなし ⎿ tank: ⎿ tank: 10-09 ░░░░░░░░░░░░ Lv.0 🥚 ✻ Thinking… Lv.0 たまご 0/500 🌊 🥚 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Thinking… Lv.0 たまご 0/500 🌊 🥚
README

dotfiles

usage

$ pwd
# -> /Users/hoge

$ git clone git@github.com:tocomi/dotfiles.git

$ cd dotfiles
$ ./setup.sh
Source 2 files
hooks/register.tsx 189 lines
1import { 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}
189
types/index.d.ts 11 lines
1export 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