An idle server farm above the prompt: daemon bots collect data from your racks, and your Claude work boosts it

Mods for Claude Code, in the terminal and in the desktop app's Code tab: little games and toys that live above the prompt, written as plugins of function hooks.

An idle game in the band above the prompt. Racks of servers fill up with data, daemon bots walk over to collect it, and you spend it on more racks, more daemons, overclocking and better hardware (Raspberry Pi, Tower, Blade, GPU rig).
Your real work is the weather:
npm test, vitest, pytest and the like) pay 30 seconds of productionThe farm keeps earning while you are away, at half rate for up to 8 hours. With several sessions open, one runs the farm and the others mirror it and send it their events.
Commands: /farm opens the shop (keys 1 to 4 buy), /farm stats, /farm hide, /farm show.
![]()
A pixel slime that hatches from an egg and grows into a teen and then a slime king as you work. It glances around while Claude works, cheers when tests pass, frowns when a tool fails and falls asleep when you leave.
Commands: /pet, /pet pat, /pet name <name>, /pet hide, /pet show.
Both mods draw the band above the prompt, so install one at a time.
/plugin marketplace add pierregoutheraud/claude-mods
/plugin install server-farm@pierre-mods
They watch turns and tool calls (to see whether a call failed or ran tests, never to change it), add their slash command, draw the band above the prompt and keep their save in their own plugin store. No network, no file access, no processes. claude plugin validate plugins/<name> lists every hook and call a mod makes.
Load a mod straight from this folder; it hot-reloads when you save:
claude --plugin-dir plugins/server-farm
To load it in every session, the desktop app included, add its absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of your Claude Code settings.json. Do not run a dev copy and an installed copy of the same mod at once.
Run the checks before committing (CI runs the same script):
./scripts/check.sh
It validates the marketplace and each plugin, runs each plugin's tests with claude plugin test, and type-checks a plugin once Claude Code has loaded it, which is when its types land in .claude-plugin/types/ (gitignored).
The function hooks API is early access and can change between Claude Code releases. These mods were built and tested on Claude Code 2.1.287, and CI runs against the latest release on every push.
Bump version in the plugin's .claude-plugin/plugin.json, commit, tag and push. Users get it with /plugin marketplace update pierre-mods.
.claude-plugin/marketplace.json the marketplace: its name and the plugins it lists
plugins/<name>/
.claude-plugin/plugin.json the plugin's manifest
hooks/hooks.json names the hooks module
hooks/register.tsx the hooks: events in, drawing out
hooks/*.ts pure logic and drawing, tested directly
types/index.d.ts the plugin's $.state contract
tests/ claude plugin test runs these
scripts/check.sh validate, test, type-checkhooks/register.tsx 405 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Farm, FarmEvent } from '../types'
5import { drawStrip, STRIP_ROWS, textStrip, visibleRacks } from './draw'
6import { applyEvent, catchUp, formatBytes, formatRate, isFarm, newFarm, ratePerSecond, shopItems, step } from './game'
7
8const SHOP = 'server-farm-shop'
9// The farm animates every tick by repainting its Raster in place (no redraw of
10// the band); the status line under it is redrawn once a second, or at once on an event.
11const TICK_MS = 250
12const PUBLISH_EVERY_MS = 1_000
13const SAVE_EVERY_MS = 5_000
14const MIRROR_EVERY_MS = 2_000
15const STALE_OWNER_MS = 20_000
16const NOTICE_MS = 5_000
17// Empty rows between the transcript and the farm.
18const BAND_SPACING = 1
19// A gap longer than this between ticks (a sleeping laptop) is paid as time away.
20const CATCH_UP_AFTER_MS = 5_000
21const TEST_COMMAND = /\b(vitest|jest|pytest|mocha|playwright test|go test|cargo test|(npm|pnpm|yarn|bun) (run )?test)\b/
22
23const farm = atom({ plugin: 'server-farm', key: 'farm' } as const, null)
24const isOwner = atom({ plugin: 'server-farm', key: 'isOwner' } as const, false)
25const notice = atom({ plugin: 'server-farm', key: 'notice' } as const, null)
26const isHidden = atom({ plugin: 'server-farm', key: 'isHidden' } as const, false)
27
28// Which session runs the farm: the others mirror its save and send it their events.
29type Owner = { id: string; beatAt: number }
30
31const isOwnerRecord = (value: unknown): value is Owner =>
32 typeof value === 'object' && value !== null && typeof (value as Owner).id === 'string'
33
34// The loop's own state; a hot reload starts it over, and session.start seeds it again.
35let sessionId = ''
36let lastTickAt = 0
37let lastSaveAt = 0
38let lastMirrorAt = 0
39let lastPublishAt = 0
40let noticeUntil = 0
41let frame = 0
42// The farm as this session runs it, ahead of what `farm` last published; null unless it runs the farm.
43let live = null as Farm | null
44// The Raster the band last drew on the terminal, which blits repaint; null when none is mounted.
45let band = null as { requestId: string; columns: number } | null
46
47async function say($: EngineInterface, text: string) {
48 noticeUntil = (await $.clock.now()) + NOTICE_MS
49 await update($, notice, () => ({ text, until: noticeUntil }))
50}
51
52async function publish($: EngineInterface, now: number) {
53 lastPublishAt = now
54 const current = live
55 if (current !== null) await update($, farm, () => current)
56 if (noticeUntil !== 0 && now >= noticeUntil) {
57 noticeUntil = 0
58 await update($, notice, () => null)
59 }
60}
61
62// Repaints the band's Raster with the next frame, without redrawing the band.
63async function paint($: EngineInterface) {
64 if (band === null || live === null) return
65 try {
66 const painted = await $.ui.blit({
67 requestId: band.requestId,
68 key: 'farm',
69 cells: drawStrip(live, band.columns, frame),
70 columns: band.columns,
71 rows: STRIP_ROWS,
72 })
73 // Not mounted any more, or another size: the next drawing of the band arms it again.
74 if (painted.deny !== undefined) band = null
75 } catch {
76 band = null
77 }
78}
79
80async function loadFarm($: EngineInterface, now: number): Promise<Farm> {
81 const stored = await $.store.get('farm')
82
83 return isFarm(stored) ? stored : newFarm(now)
84}
85
86async function save($: EngineInterface, now: number) {
87 const current = live
88 if (current === null) return
89
90 await $.store.set('farm', { ...current, savedAt: now })
91 await $.store.set('owner', { id: sessionId, beatAt: now })
92 lastSaveAt = now
93}
94
95// Takes the farm when nobody runs it, this session already did, or its runner went quiet.
96async function claim($: EngineInterface, now: number): Promise<boolean> {
97 const owner = await $.store.get('owner')
98 const isFree = !isOwnerRecord(owner) || owner.id === sessionId || now - owner.beatAt > STALE_OWNER_MS
99 if (isFree) await $.store.set('owner', { id: sessionId, beatAt: now })
100 await update($, isOwner, () => isFree)
101
102 return isFree
103}
104
105// Starts running the farm here, from `seed` (what this session last published, after a hot
106// reload) or else from the save.
107async function takeOver($: EngineInterface, now: number, seed: Farm | null) {
108 const loaded = seed ?? (await loadFarm($, now))
109 const { farm: caught, earned } = catchUp(loaded, now - loaded.savedAt)
110 live = caught
111 await update($, isOwner, () => true)
112 await publish($, now)
113 await save($, now)
114 if (earned > 0) $.ui.toast(`While you were away, your farm produced ${formatBytes(earned)}`)
115}
116
117async function apply($: EngineInterface, event: FarmEvent) {
118 if (live === null) return
119
120 const result = applyEvent(live, event)
121 live = result.farm
122 if (result.notice !== null) await say($, result.notice)
123 await publish($, await $.clock.now())
124}
125
126// Events from this session: applied here when it runs the farm, else queued for the session that does.
127async function send($: EngineInterface, event: FarmEvent) {
128 if (live !== null) {
129 await apply($, event)
130 if (event.kind === 'buy') await save($, await $.clock.now())
131
132 return
133 }
134
135 const inbox = await $.store.get('inbox')
136 await $.store.set('inbox', [...(Array.isArray(inbox) ? inbox : []), event].slice(-50))
137 if (event.kind === 'buy') await say($, 'Sent to the session running the farm')
138}
139
140async function drainInbox($: EngineInterface) {
141 const inbox = await $.store.get('inbox')
142 if (!Array.isArray(inbox) || inbox.length === 0) return
143
144 await $.store.set('inbox', [])
145 for (const event of inbox as FarmEvent[]) await apply($, event)
146}
147
148async function tick($: EngineInterface) {
149 const now = await $.clock.now()
150 const elapsed = now - lastTickAt
151 lastTickAt = now
152 frame += 1
153
154 if (live === null) {
155 if (now - lastMirrorAt < MIRROR_EVERY_MS) return
156 lastMirrorAt = now
157 if (noticeUntil !== 0 && now >= noticeUntil) await publish($, now)
158 if (await claim($, now)) {
159 await takeOver($, now, null)
160 } else {
161 const mirrored = await loadFarm($, now)
162 await update($, farm, () => mirrored)
163 }
164
165 return
166 }
167
168 if (elapsed <= CATCH_UP_AFTER_MS) {
169 live = step(live, elapsed)
170 } else {
171 const caught = catchUp(live, elapsed)
172 live = caught.farm
173 if (caught.earned > 0) $.ui.toast(`While you were away, your farm produced ${formatBytes(caught.earned)}`)
174 }
175
176 await paint($)
177 if (now - lastPublishAt >= PUBLISH_EVERY_MS) await publish($, now)
178
179 if (now - lastSaveAt >= SAVE_EVERY_MS) {
180 // Another session may have taken over while this one slept.
181 const owner = await $.store.get('owner')
182 if (isOwnerRecord(owner) && owner.id !== sessionId && now - owner.beatAt <= STALE_OWNER_MS) {
183 live = null
184 await update($, isOwner, () => false)
185
186 return
187 }
188 await drainInbox($)
189 await save($, now)
190 }
191}
192
193async function openShop($: EngineInterface) {
194 await $.ui.open({ id: SHOP, title: 'Server farm shop', focus: true, closeOnEscape: true })
195}
196
197async function closeShop($: EngineInterface) {
198 await $.ui.close({ id: SHOP })
199}
200
201async function setHidden($: EngineInterface, hidden: boolean) {
202 await update($, isHidden, () => hidden)
203 await $.store.set('isHidden', hidden)
204}
205
206export const register: Register = on => {
207 on('session.start', async ($, e, next) => {
208 const now = await $.clock.now()
209 sessionId = await $.session.id()
210 lastTickAt = now
211 lastSaveAt = now
212 lastMirrorAt = now
213 lastPublishAt = now
214 live = null
215 band = null
216
217 await update($, isHidden, () => false)
218 if ((await $.store.get('isHidden')) === true) await update($, isHidden, () => true)
219
220 await $.command.register({
221 name: 'farm',
222 description: 'Your server farm: /farm opens the shop, /farm stats, /farm hide, /farm show',
223 })
224
225 // A headless run never runs the farm: it only sends its events to the session that does.
226 if (e.isInteractive) {
227 // A hot reload of the session running the farm picks up where it left off.
228 const seed = (await read($, isOwner)) ? await read($, farm) : null
229 if (await claim($, now)) {
230 await takeOver($, now, seed)
231 } else {
232 const mirrored = await loadFarm($, now)
233 await update($, farm, () => mirrored)
234 }
235
236 $.clock.every(TICK_MS, () => {
237 void tick($).catch(() => undefined)
238 })
239 }
240
241 return next(e)
242 })
243
244 on('session.end', async ($, e, next) => {
245 if (e.reason !== 'clear' && live !== null) {
246 await save($, await $.clock.now())
247 await $.store.delete('owner')
248 }
249
250 return next(e)
251 })
252
253 on('turn.complete', async ($, e, next) => {
254 if (e.agentId === undefined && e.reason === 'answer') await send($, { kind: 'turn' })
255
256 return next(e)
257 })
258
259 on('tool.call', async ($, e, next) => {
260 const ran = await next(e)
261 if (ran.deny !== undefined) return ran
262
263 if (ran.isError === true) await send($, { kind: 'failure' })
264 else if (e.tool === 'Bash' && TEST_COMMAND.test(e.command)) await send($, { kind: 'tests' })
265
266 return ran
267 })
268
269 on('command.run', { command: 'farm' }, async ($, e) => {
270 const current = await read($, farm)
271 switch (e.args.trim()) {
272 case '':
273 await setHidden($, false)
274 await openShop($)
275
276 return { text: 'Server farm shop opened.' }
277 case 'stats': {
278 if (current === null) return { text: 'The farm is still booting.' }
279 const racks = current.racks.length
280 const daemons = current.daemons.map(daemon => daemon.name).join(', ')
281
282 return {
283 text: `${formatBytes(current.bytes)} stored, +${formatRate(ratePerSecond(current))}. ${racks} racks, daemons: ${daemons}. Lifetime ${formatBytes(current.lifetimeBytes)}, ${current.incidents} incidents.`,
284 }
285 }
286 case 'hide':
287 await setHidden($, true)
288
289 return { text: 'The farm keeps running out of sight. /farm show brings it back.' }
290 case 'show':
291 await setHidden($, false)
292
293 return { text: 'The farm is back above the prompt.' }
294 default:
295 return { text: 'Usage: /farm [stats | hide | show]' }
296 }
297 })
298
299 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
300 const current = await read($, farm)
301 if (e.props.hasSurvey || current === null || (await read($, isHidden))) {
302 band = null
303
304 return next(e)
305 }
306
307 const beat = frame
308 const shown = await read($, notice)
309 const owner = await read($, isOwner)
310 const columns = Math.min(e.props.bodyColumns, 512)
311 const rate = ratePerSecond(current)
312 const offScreen = current.racks.length - visibleRacks(current, columns)
313 const { Box, Button, Text } = $.ui.resolve(e)
314
315 if (columns < 40) {
316 band = null
317
318 return (
319 <Box marginTop={BAND_SPACING}>
320 <Text wrap="truncate">
321 {formatBytes(current.bytes)} +{formatRate(rate)}
322 </Text>
323 </Box>
324 )
325 }
326
327 const status = (
328 <Box flexDirection="row" gap={2}>
329 <Text bold color="#7ee787">
330 {formatBytes(current.bytes)}
331 </Text>
332 <Text dimColor>+{formatRate(rate)}</Text>
333 {current.spikeMs > 0 && <Text color="#ffd166">traffic spike x2 {Math.ceil(current.spikeMs / 1000)}s</Text>}
334 {offScreen > 0 && <Text dimColor>+{offScreen} racks off screen</Text>}
335 {!owner && <Text dimColor>mirroring the farm of another session</Text>}
336 {shown !== null && (
337 <Text italic wrap="truncate">
338 {shown.text}
339 </Text>
340 )}
341 <Button key="shop" label="shop" hotkey="s" onPress={() => openShop($)} />
342 </Box>
343 )
344
345 if (e.surface === 'terminal') {
346 const { Raster } = $.ui.resolve(e)
347 band = { requestId: e.requestId, columns }
348
349 return (
350 <Box flexDirection="column" marginTop={BAND_SPACING}>
351 <Raster key="farm" columns={columns} rows={STRIP_ROWS} cells={drawStrip(current, columns, beat)} />
352 {status}
353 </Box>
354 )
355 }
356
357 return (
358 <Box flexDirection="column" marginTop={BAND_SPACING}>
359 <Text>{textStrip(current, beat)}</Text>
360 {status}
361 </Box>
362 )
363 })
364
365 on('ui.render', { component: 'Pane', requestId: SHOP }, async ($, e) => {
366 const current = await read($, farm)
367 const { Box, Button, Text } = $.ui.resolve(e)
368 if (current === null) return <Text dimColor>The farm is still booting.</Text>
369
370 const shown = await read($, notice)
371 const daemons = current.daemons.map(daemon => daemon.name).join(', ')
372
373 return (
374 <Box flexDirection="column" gap={1}>
375 <Box flexDirection="column">
376 <Text bold color="#7ee787">
377 {formatBytes(current.bytes)} stored, +{formatRate(ratePerSecond(current))}
378 </Text>
379 <Text dimColor>
380 {current.racks.length} racks, daemons: {daemons}, overclock level {current.overclock}
381 </Text>
382 </Box>
383 <Box flexDirection="column">
384 {shopItems(current).map((item, index) => (
385 <Button
386 key={`buy-${item.id}`}
387 hotkey={String(index + 1)}
388 label={item.cost === null ? `${item.label} (sold out)` : `${item.label} (${formatBytes(item.cost)})`}
389 dimColor={item.cost === null || current.bytes < item.cost}
390 plain
391 onPress={() => send($, { kind: 'buy', item: item.id })}
392 />
393 ))}
394 </Box>
395 {shown !== null && <Text italic>{shown.text}</Text>}
396 <Text dimColor>
397 Finished turns bring traffic spikes, passing tests pay a bonus, failing tools take a rack down.
398 Lifetime {formatBytes(current.lifetimeBytes)}, {current.incidents} incidents.
399 </Text>
400 <Button key="close" label="close" role="dismiss" onPress={() => closeShop($)} />
401 </Box>
402 )
403 })
404}
405hooks/draw.ts 170 lines1import type { Daemon, Farm, Rack, ServerKind } from '../types'
2import { RACK_WIDTH, rackX } from './game'
3
4// The strip is 10 pixels tall, two per terminal row with half blocks: racks on
5// top, a lane below them where the daemons walk, and the floor.
6export const STRIP_ROWS = 5
7const HEIGHT = STRIP_ROWS * 2
8const FLOOR_Y = HEIGHT - 1
9const RACK_TOP = 0
10const RACK_HEIGHT = 6
11const LEDS = 4
12
13const COLORS = {
14 floor: '#30363d',
15 inside: '#161b22',
16 bay: '#30363d',
17 led: '#7ee787',
18 ledOff: '#21262d',
19 ready: '#ffd166',
20 down: '#ff6b6b',
21 activity: '#58a6ff',
22 visor: '#56d4dd',
23 packet: '#ffd166',
24}
25
26const FRAMES: Record<ServerKind, string> = {
27 pi: '#4b5563',
28 tower: '#9ca3af',
29 blade: '#58a6ff',
30 gpu: '#3fb950',
31}
32
33const DAEMON_COLORS = ['#e5c07b', '#c678dd', '#56b6c2', '#e06c75', '#98c379', '#d19a66']
34
35type Canvas = (string | undefined)[][]
36
37const paint = (canvas: Canvas, x: number, y: number, color: string) => {
38 const row = canvas[y]
39 if (row !== undefined && x >= 0 && x < row.length) row[x] = color
40}
41
42export const visibleRacks = (farm: Farm, columns: number): number =>
43 farm.racks.filter((_, index) => rackX(index) + RACK_WIDTH <= columns).length
44
45const drawRack = (canvas: Canvas, rack: Rack, index: number, frame: number, isSpiking: boolean) => {
46 const left = rackX(index)
47 const right = left + RACK_WIDTH - 1
48 const bottom = RACK_TOP + RACK_HEIGHT - 1
49
50 for (let y = RACK_TOP; y <= bottom; y++) {
51 for (let x = left; x <= right; x++) {
52 const isEdge = y === RACK_TOP || y === bottom || x === left || x === right
53 paint(canvas, x, y, isEdge ? FRAMES[rack.kind] : COLORS.inside)
54 }
55 }
56
57 // Drive bays for texture.
58 for (const y of [RACK_TOP + 2, RACK_TOP + 4]) {
59 paint(canvas, left + 3, y, COLORS.bay)
60 paint(canvas, left + 4, y, COLORS.bay)
61 }
62
63 // Two columns of LEDs fill from the bottom as the batch builds up.
64 const lit = Math.floor(rack.progress * LEDS)
65 for (let level = 0; level < LEDS; level++) {
66 const y = bottom - 1 - level
67 const color = rack.isDown
68 ? frame % 2 === 0
69 ? COLORS.down
70 : COLORS.ledOff
71 : rack.progress >= 1
72 ? frame % 4 < 2
73 ? COLORS.ready
74 : COLORS.ledOff
75 : level < lit
76 ? COLORS.led
77 : COLORS.ledOff
78 paint(canvas, left + 1, y, color)
79 paint(canvas, left + 2, y, color)
80 }
81
82 // The activity light flickers while the rack works, faster during a traffic spike.
83 const isWorking = !rack.isDown && rack.progress < 1
84 const isOn = isWorking && (frame + index) % (isSpiking ? 2 : 4) === 0
85 paint(canvas, left + 4, RACK_TOP + 1, isOn ? COLORS.activity : COLORS.inside)
86}
87
88// A daemon is a 4x4 robot with a visor, walking the lane below the racks. While it
89// works, a packet blinks between it and the rack.
90const drawDaemon = (canvas: Canvas, daemon: Daemon, index: number, frame: number) => {
91 const x = Math.round(daemon.x)
92 const body = DAEMON_COLORS[index % DAEMON_COLORS.length] ?? COLORS.visor
93 const isWorking = daemon.workMs > 0
94 const isWalking = daemon.target !== null && !isWorking
95 const top = FLOOR_Y - 3
96
97 if (isWorking && frame % 2 === 0) paint(canvas, x + 2, top - 1, COLORS.packet)
98 for (const dx of [1, 2]) paint(canvas, x + dx, top, body)
99 paint(canvas, x, top + 1, body)
100 paint(canvas, x + 1, top + 1, COLORS.visor)
101 paint(canvas, x + 2, top + 1, COLORS.visor)
102 paint(canvas, x + 3, top + 1, body)
103 for (let dx = 0; dx < 4; dx++) paint(canvas, x + dx, top + 2, body)
104
105 const legs = isWalking && frame % 2 === 1 ? [1, 2] : [0, 3]
106 for (const dx of legs) paint(canvas, x + dx, top + 3, body)
107}
108
109const DEFAULT_COLOR = 0x01000000
110const UPPER_HALF = 0x2580
111const LOWER_HALF = 0x2584
112const SPACE = 0x20
113
114const hex = (color: string | undefined): number | undefined =>
115 color === undefined ? undefined : parseInt(color.slice(1), 16)
116
117// Packs a canvas into RasterProps cells: [codePoint, fg, bg] u32 triplets, base64.
118const encode = (canvas: Canvas, columns: number): string => {
119 const words = new Uint32Array(columns * STRIP_ROWS * 3)
120 for (let row = 0; row < STRIP_ROWS; row++) {
121 for (let x = 0; x < columns; x++) {
122 const top = hex(canvas[2 * row]?.[x])
123 const bottom = hex(canvas[2 * row + 1]?.[x])
124 const cell =
125 top !== undefined
126 ? [UPPER_HALF, top, bottom ?? DEFAULT_COLOR]
127 : bottom !== undefined
128 ? [LOWER_HALF, bottom, DEFAULT_COLOR]
129 : [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
130 words.set(cell, (row * columns + x) * 3)
131 }
132 }
133
134 const bytes = new Uint8Array(words.buffer)
135 let binary = ''
136 for (const byte of bytes) binary += String.fromCharCode(byte)
137
138 return btoa(binary)
139}
140
141export const drawCanvas = (farm: Farm, columns: number, frame: number): Canvas => {
142 const canvas: Canvas = Array.from({ length: HEIGHT }, () => new Array<string | undefined>(columns).fill(undefined))
143 for (let x = 0; x < columns; x++) paint(canvas, x, FLOOR_Y, COLORS.floor)
144
145 const shown = visibleRacks(farm, columns)
146 farm.racks.slice(0, shown).forEach((rack, index) => drawRack(canvas, rack, index, frame, farm.spikeMs > 0))
147 farm.daemons.forEach((daemon, index) => drawDaemon(canvas, daemon, index, frame))
148
149 return canvas
150}
151
152export const drawStrip = (farm: Farm, columns: number, frame: number): string =>
153 encode(drawCanvas(farm, columns, frame), columns)
154
155const LEVELS = '▁▂▃▄▅▆▇█'
156
157// The farm as text, for surfaces without Raster (desktop, VS Code, mobile).
158export const textStrip = (farm: Farm, frame: number): string =>
159 farm.racks
160 .map(rack =>
161 rack.isDown
162 ? '[x]'
163 : rack.progress >= 1
164 ? frame % 4 < 2
165 ? '[*]'
166 : '[ ]'
167 : `[${LEVELS[Math.min(LEVELS.length - 1, Math.floor(rack.progress * LEVELS.length))]}]`,
168 )
169 .join('')
170hooks/game.ts 297 lines1import type { Daemon, Farm, FarmEvent, Rack, ServerKind, ShopItemId } from '../types'
2
3// Layout along the strip, in pixels: a rack is 6 wide, one every 8.
4export const RACK_WIDTH = 6
5export const RACK_STRIDE = 8
6export const STRIP_LEFT = 1
7
8export const MAX_RACKS = 12
9export const MAX_DAEMONS = 6
10export const MAX_OVERCLOCK = 10
11
12const DAEMON_SPEED = 12 // pixels per second
13const COLLECT_MS = 600
14const FIX_MS = 4_000
15const SPIKE_MS = 60_000
16const MAX_SPIKE_MS = 300_000
17const OFFLINE_CAP_MS = 8 * 60 * 60 * 1000
18const OFFLINE_EFFICIENCY = 0.5
19const STEP_MS = 250
20
21export const DAEMON_NAMES = ['Byte', 'Nibble', 'Bit', 'Word', 'Qword', 'Kilo']
22
23export const SERVERS: Record<ServerKind, { label: string; cycleMs: number; yield: number; cost: number }> = {
24 pi: { label: 'Raspberry Pi', cycleMs: 10_000, yield: 8, cost: 0 },
25 tower: { label: 'Tower server', cycleMs: 30_000, yield: 60, cost: 250 },
26 blade: { label: 'Blade server', cycleMs: 90_000, yield: 400, cost: 2_000 },
27 gpu: { label: 'GPU rig', cycleMs: 240_000, yield: 2_400, cost: 16_000 },
28}
29
30const TIERS: readonly ServerKind[] = ['pi', 'tower', 'blade', 'gpu']
31
32export const rackX = (index: number): number => STRIP_LEFT + index * RACK_STRIDE
33
34// Where a daemon stands to work on a rack.
35const standX = (index: number): number => rackX(index) + 1
36
37const newRack = (): Rack => ({ kind: 'pi', progress: 0, isDown: false })
38
39const newDaemon = (index: number): Daemon => ({
40 name: DAEMON_NAMES[index] ?? `Daemon ${index + 1}`,
41 x: standX(0),
42 target: null,
43 workMs: 0,
44})
45
46export const newFarm = (now: number): Farm => ({
47 version: 1,
48 bytes: 0,
49 lifetimeBytes: 0,
50 racks: [newRack(), newRack()],
51 daemons: [newDaemon(0)],
52 overclock: 0,
53 spikeMs: 0,
54 incidents: 0,
55 savedAt: now,
56})
57
58export const isFarm = (value: unknown): value is Farm => {
59 const farm = value as Farm
60
61 return (
62 typeof value === 'object' &&
63 value !== null &&
64 farm.version === 1 &&
65 typeof farm.bytes === 'number' &&
66 Array.isArray(farm.racks) &&
67 Array.isArray(farm.daemons)
68 )
69}
70
71const speedOf = (farm: Farm): number => (1 + 0.2 * farm.overclock) * (farm.spikeMs > 0 ? 2 : 1)
72
73export const ratePerSecond = (farm: Farm): number =>
74 farm.racks.reduce(
75 (sum, rack) => (rack.isDown ? sum : sum + (SERVERS[rack.kind].yield * 1000) / SERVERS[rack.kind].cycleMs),
76 0,
77 ) * speedOf(farm)
78
79// The nearest rack that is full or down and that no other daemon has claimed.
80const pickTarget = (racks: readonly Rack[], x: number, taken: ReadonlySet<number>): number | null => {
81 let best = null as number | null
82 racks.forEach((rack, index) => {
83 const needsWork = rack.isDown || rack.progress >= 1
84 if (!needsWork || taken.has(index)) return
85 if (best === null || Math.abs(standX(index) - x) < Math.abs(standX(best) - x)) best = index
86 })
87
88 return best
89}
90
91const jobMs = (rack: Rack | undefined): number => (rack?.isDown ? FIX_MS : COLLECT_MS)
92
93const tick = (farm: Farm, dt: number): Farm => {
94 const speed = speedOf(farm)
95 const racks = farm.racks.map(rack =>
96 rack.isDown || rack.progress >= 1
97 ? rack
98 : { ...rack, progress: Math.min(1, rack.progress + (dt * speed) / SERVERS[rack.kind].cycleMs) },
99 )
100 const taken = new Set(farm.daemons.flatMap(daemon => (daemon.target === null ? [] : [daemon.target])))
101 let bytes = farm.bytes
102 let lifetimeBytes = farm.lifetimeBytes
103
104 const daemons = farm.daemons.map(daemon => {
105 let { x, target, workMs } = daemon
106
107 if (target === null || target >= racks.length) {
108 target = pickTarget(racks, x, taken)
109 workMs = 0
110 if (target !== null) taken.add(target)
111 }
112 if (target === null) return { ...daemon, target, workMs }
113
114 const goal = standX(target)
115 if (x !== goal) {
116 const move = (DAEMON_SPEED * dt) / 1000
117 x = Math.abs(goal - x) <= move ? goal : x + Math.sign(goal - x) * move
118 if (x === goal) workMs = jobMs(racks[target])
119
120 return { ...daemon, x, target, workMs }
121 }
122
123 if (workMs === 0) workMs = jobMs(racks[target])
124 workMs -= dt
125 if (workMs > 0) return { ...daemon, x, target, workMs }
126
127 const rack = racks[target]
128 if (rack?.isDown) {
129 racks[target] = { ...rack, isDown: false }
130 } else if (rack !== undefined && rack.progress >= 1) {
131 bytes += SERVERS[rack.kind].yield
132 lifetimeBytes += SERVERS[rack.kind].yield
133 racks[target] = { ...rack, progress: 0 }
134 }
135 taken.delete(target)
136
137 return { ...daemon, x, target: null, workMs: 0 }
138 })
139
140 return { ...farm, racks, daemons, bytes, lifetimeBytes, spikeMs: Math.max(0, farm.spikeMs - dt) }
141}
142
143// Runs the farm forward, in steps short enough for daemons to walk smoothly.
144export const step = (farm: Farm, elapsedMs: number): Farm => {
145 let next = farm
146 for (let left = elapsedMs; left > 0; left -= STEP_MS) next = tick(next, Math.min(STEP_MS, left))
147
148 return next
149}
150
151// Time the farm was not watched: paid at half rate, up to 8 hours, racks left full.
152export const catchUp = (farm: Farm, elapsedMs: number): { farm: Farm; earned: number } => {
153 const ms = Math.min(Math.max(0, elapsedMs), OFFLINE_CAP_MS)
154 const speed = 1 + 0.2 * farm.overclock
155 const perMs = farm.racks.reduce(
156 (sum, rack) => (rack.isDown ? sum : sum + SERVERS[rack.kind].yield / SERVERS[rack.kind].cycleMs),
157 0,
158 )
159 const earned = Math.floor(perMs * speed * ms * OFFLINE_EFFICIENCY)
160 const racks = farm.racks.map(rack =>
161 rack.isDown ? rack : { ...rack, progress: Math.min(1, rack.progress + (ms * speed) / SERVERS[rack.kind].cycleMs) },
162 )
163
164 return {
165 farm: {
166 ...farm,
167 racks,
168 bytes: farm.bytes + earned,
169 lifetimeBytes: farm.lifetimeBytes + earned,
170 spikeMs: Math.max(0, farm.spikeMs - ms),
171 },
172 earned,
173 }
174}
175
176const upgradeTarget = (farm: Farm): { index: number; to: ServerKind } | null => {
177 // Spelled with `as` so TypeScript keeps the type through the forEach assignments.
178 let found = null as { index: number; tier: number } | null
179 farm.racks.forEach((rack, index) => {
180 const tier = TIERS.indexOf(rack.kind)
181 if (tier < TIERS.length - 1 && (found === null || tier < found.tier)) found = { index, tier }
182 })
183 if (found === null) return null
184
185 const { index, tier } = found
186 const to = TIERS[tier + 1]
187
188 return to === undefined ? null : { index, to }
189}
190
191export type ShopItem = { id: ShopItemId; label: string; cost: number | null }
192
193export const shopItems = (farm: Farm): ShopItem[] => {
194 const upgrade = upgradeTarget(farm)
195 const from = upgrade === null ? undefined : farm.racks[upgrade.index]
196
197 return [
198 {
199 id: 'rack',
200 label: 'New rack',
201 cost: farm.racks.length >= MAX_RACKS ? null : Math.round(40 * 1.6 ** (farm.racks.length - 2)),
202 },
203 {
204 id: 'daemon',
205 label: `Hire ${DAEMON_NAMES[farm.daemons.length] ?? 'a daemon'}`,
206 cost: farm.daemons.length >= MAX_DAEMONS ? null : Math.round(120 * 3 ** (farm.daemons.length - 1)),
207 },
208 {
209 id: 'overclock',
210 label: `Overclock to level ${farm.overclock + 1}`,
211 cost: farm.overclock >= MAX_OVERCLOCK ? null : Math.round(80 * 2.2 ** farm.overclock),
212 },
213 upgrade === null || from === undefined
214 ? { id: 'upgrade', label: 'Upgrade a rack', cost: null }
215 : {
216 id: 'upgrade',
217 label: `Rack ${upgrade.index + 1}: ${SERVERS[from.kind].label} to ${SERVERS[upgrade.to].label}`,
218 cost: SERVERS[upgrade.to].cost,
219 },
220 ]
221}
222
223export const buy = (farm: Farm, id: ShopItemId): { farm: Farm; notice: string } => {
224 const item = shopItems(farm).find(one => one.id === id)
225 if (item === undefined || item.cost === null) return { farm, notice: 'Sold out' }
226 if (farm.bytes < item.cost) return { farm, notice: `Not enough data for ${item.label}` }
227
228 const paid = { ...farm, bytes: farm.bytes - item.cost }
229 const upgrade = upgradeTarget(farm)
230 switch (id) {
231 case 'rack':
232 return { farm: { ...paid, racks: [...paid.racks, newRack()] }, notice: 'A new rack is online' }
233 case 'daemon':
234 return {
235 farm: { ...paid, daemons: [...paid.daemons, newDaemon(paid.daemons.length)] },
236 notice: `${DAEMON_NAMES[paid.daemons.length] ?? 'A daemon'} joined the farm`,
237 }
238 case 'overclock':
239 return { farm: { ...paid, overclock: paid.overclock + 1 }, notice: `Overclocked to level ${paid.overclock + 1}` }
240 case 'upgrade': {
241 if (upgrade === null) return { farm, notice: 'Sold out' }
242 const racks = paid.racks.map((rack, index) =>
243 index === upgrade.index ? { kind: upgrade.to, progress: 0, isDown: false } : rack,
244 )
245
246 return { farm: { ...paid, racks }, notice: `Rack ${upgrade.index + 1} is now a ${SERVERS[upgrade.to].label}` }
247 }
248 }
249}
250
251// What Claude Code activity does to the farm, and the line the band shows for it.
252export const applyEvent = (farm: Farm, event: FarmEvent): { farm: Farm; notice: string | null } => {
253 switch (event.kind) {
254 case 'turn':
255 return {
256 farm: { ...farm, spikeMs: Math.min(MAX_SPIKE_MS, farm.spikeMs + SPIKE_MS) },
257 notice: farm.spikeMs > 0 ? 'Traffic spike extended' : 'Traffic spike! Production x2',
258 }
259 case 'tests': {
260 const bonus = Math.max(16, Math.floor(ratePerSecond({ ...farm, spikeMs: 0 }) * 30))
261
262 return {
263 farm: { ...farm, bytes: farm.bytes + bonus, lifetimeBytes: farm.lifetimeBytes + bonus },
264 notice: `Green build! +${formatBytes(bonus)}`,
265 }
266 }
267 case 'failure': {
268 const up = farm.racks.flatMap((rack, index) => (rack.isDown ? [] : [index]))
269 const down = farm.racks.length - up.length
270 const index = up[farm.incidents % Math.max(1, up.length)]
271 if (index === undefined || down >= Math.ceil(farm.racks.length / 3)) return { farm, notice: null }
272
273 const racks = farm.racks.map((rack, i) => (i === index ? { ...rack, isDown: true } : rack))
274
275 return { farm: { ...farm, racks, incidents: farm.incidents + 1 }, notice: `Incident! Rack ${index + 1} went down` }
276 }
277 case 'buy':
278 return buy(farm, event.item)
279 }
280}
281
282const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
283
284export const formatBytes = (bytes: number): string => {
285 let value = Math.floor(bytes)
286 let unit = 0
287 while (value >= 1024 && unit < UNITS.length - 1) {
288 value /= 1024
289 unit += 1
290 }
291
292 return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${UNITS[unit]}`
293}
294
295export const formatRate = (perSecond: number): string =>
296 perSecond < 10 ? `${perSecond.toFixed(1)} B/s` : `${formatBytes(perSecond)}/s`
297types/index.d.ts 48 lines1export type ServerKind = 'pi' | 'tower' | 'blade' | 'gpu'
2
3export type Rack = {
4 kind: ServerKind
5 // 0 to 1; at 1 the rack holds a full batch of data waiting for a daemon.
6 progress: number
7 isDown: boolean
8}
9
10export type Daemon = {
11 name: string
12 // Position along the strip, in pixels.
13 x: number
14 // The rack it is heading to or working on.
15 target: number | null
16 // Time left on the job at the target; 0 while walking or idle.
17 workMs: number
18}
19
20export type Farm = {
21 version: 1
22 bytes: number
23 lifetimeBytes: number
24 racks: Rack[]
25 daemons: Daemon[]
26 overclock: number
27 spikeMs: number
28 incidents: number
29 savedAt: number
30}
31
32export type ShopItemId = 'rack' | 'daemon' | 'overclock' | 'upgrade'
33
34export type FarmEvent = { kind: 'turn' } | { kind: 'tests' } | { kind: 'failure' } | { kind: 'buy'; item: ShopItemId }
35
36export type FarmNotice = { text: string; until: number }
37
38declare module 'claude-code' {
39 interface PluginState {
40 'server-farm': {
41 farm: Farm | null
42 isOwner: boolean
43 notice: FarmNotice | null
44 isHidden: boolean
45 }
46 }
47}
48