SLOPSHOPPER

reef

Your homelab as an aquarium (/reef): every Docker container is a fish coloured by its stack, unhealthy ones float belly-up, the Ollama GPU is a heater that…

newpanecommandnetworktimer
v0.1.0MITupdated 2026-10-12davidcoulson/crab-workshop/plugins/reef
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reef
│ ┃ Reef ✕ › fix the failing auth test and add an audit log call │ ┃ Filling the tank… │ ┃ Refresh ⏺ 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 │ │ › /reef │ ⎿ reef: Reef opened; filling the tank. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Reef
Filling the tank… Refresh
README

crab-workshop

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.

The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.

ModWhat you getCommands
where-am-iStatus line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container./where
roll-callA pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message.`/rollcall [open\linked] [text], tool report`
fuel-gauge⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot./fuel [text]
gpu-crabWhile a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud./gpu
vitals-stripUnraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green.`/vitals [show\quiet]`
reefAn aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name./reef [text]
keymasterMasks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan./keymaster, /keymaster allow <tag>
trailer-guardRefuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable)./trailers
gatekeeperA pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you./gates [text], tool gates
codex-cornerA boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch./codex-card
house-rulesNotes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own./rules, /rules add …, /rules off <id>

Data sources

Everything is read-only.

ModReads
roll-call, fuel-gauge, reefThe desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage)
vitals-stripThe HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read
reefunraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview)
gpu-crab, reefOllama GET /api/ps at the ollamaUrl option (default http://localhost:11434)
gatekeepergh api graphql, one search query every 5 minutes and shortly after a push
where-am-i, trailer-guard, codex-cornergit in the session's checkout

Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:

"pluginConfigs": {
  "gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
  "vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
  "where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}

Trying them

For one terminal session:

claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge

For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}

Or install from this folder as a marketplace, so /reload-plugins picks up edits:

claude plugin marketplace add ~/dev/crab-workshop

Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.

Developing

sh scripts/check.sh            # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod

shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.

Known limits

  • The desktop Code tab's drawing of AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.
  • The Unraid Management Agent's own API (:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead.
  • keymaster masks what looks like a secret. A real value that slips past its patterns is not caught, and a fake one in a test fixture is masked until you /keymaster allow its tag.
  • gatekeeper reads the first 50 PRs of its search (newest first).

License

MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.

Source 5 files
hooks/register.tsx 155 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Tank } from '../types'
5import { STACKS, fishFrom, scene, summary } from './logic'
6import { CRAB_CSS, ROTATION, crab } from './shared/crab'
7import { THEME_CSS, widthFor } from './shared/draw'
8
9const tank = atom({ plugin: 'reef', key: 'tank' } as const, { fish: [], models: [], ha: null, crabs: [], at: 0, errors: [] })
10
11const PANE = 'reef'
12const TITLE = 'Reef'
13
14let unraidServer = 'plugin:unraid-mcp:unraid-mcp'
15let haServer = ''
16let ollamaUrl = 'http://localhost:11434'
17let swimmers = 24
18let filling: Promise<string> | null = null
19
20const textOf = (r: { content: readonly unknown[] }): string =>
21  r.content.map(b => (b && typeof b === 'object' && 'text' in b && typeof b.text === 'string' ? b.text : '')).join('')
22
23const parse = (text: string): Record<string, unknown> | null => {
24  try {
25    const v = JSON.parse(text.slice(Math.max(0, Math.min(...['{', '['].map(c => (text.indexOf(c) < 0 ? Infinity : text.indexOf(c)))))))
26    return v && typeof v === 'object' ? (v as Record<string, unknown>) : null
27  } catch {
28    return null
29  }
30}
31
32/** Rejects after `ms` (the host's fetch has no timeout of its own). */
33function within<T>($: EngineInterface, p: Promise<T>, ms: number): Promise<T> {
34  return new Promise<T>((resolve, reject) => {
35    const t = $.clock.after(ms, () => reject(new Error(`no answer in ${ms / 1000}s`)))
36    p.then(
37      v => (t.cancel(), resolve(v)),
38      err => (t.cancel(), reject(err)),
39    )
40  })
41}
42
43function fill($: EngineInterface): Promise<string> {
44  if (!filling)
45    filling = fillNow($).finally(() => {
46      filling = null
47    })
48  return filling
49}
50
51async function fillNow($: EngineInterface): Promise<string> {
52  const errors: string[] = []
53  const grab = async <T,>(name: string, p: Promise<T>): Promise<T | null> => {
54    try {
55      return await p
56    } catch (err) {
57      errors.push(`${name}: ${(err instanceof Error ? err.message : String(err)).slice(0, 100)}`)
58      return null
59    }
60  }
61  const [docker, ha, ps, sessions] = await Promise.all([
62    grab('unraid', $.mcp.call(unraidServer, 'unraid', { action: 'docker', subaction: 'list', limit: 0 })),
63    haServer ? grab('home assistant', $.mcp.call(haServer, 'ha_get_overview', { fields: ['system_info', 'notification_count', 'repair_count'] })) : Promise.resolve(null),
64    grab('ollama', within($, $.http.fetch(`${ollamaUrl}/api/ps`), 3000)),
65    grab('sessions', $.mcp.call('ccd_session_mgmt', 'list_sessions', { linked: true, limit: 60 })),
66  ])
67  const prev = await read($, tank)
68  const d = docker && !docker.isError ? parse(textOf(docker)) : null
69  const containers = ((d?.result as { containers?: unknown[] } | undefined)?.containers ?? null) as Parameters<typeof fishFrom>[0] | null
70  const h = ha && !ha.isError ? parse(textOf(ha)) : null
71  const sys = (h?.system_info ?? {}) as { state?: string }
72  const p = ps && ps.ok ? parse(ps.text) : null
73  const s = sessions && !sessions.isError ? (JSON.parse(textOf(sessions).slice(textOf(sessions).indexOf('['))) as { sessionId?: string; name?: string; isRunning?: boolean }[]) : null
74  const at = await $.clock.now()
75  const next = await update($, tank, () => ({
76    fish: containers ? fishFrom(containers) : prev.fish,
77    models: p ? ((p.models as { name?: string }[] | undefined) ?? []).map(m => m.name ?? '?') : prev.models,
78    ha: h ? { repairs: Number(h.repair_count ?? 0), notes: Number(h.notification_count ?? 0), isRunning: (sys.state ?? 'RUNNING') === 'RUNNING' } : prev.ha,
79    crabs: s ? s.filter(x => x.isRunning).map(x => x.name ?? x.sessionId ?? '?') : prev.crabs,
80    at,
81    errors,
82  }))
83  return summary(next)
84}
85
86export const register: Register = (on, options) => {
87  unraidServer = String(options.unraidServer ?? unraidServer)
88  haServer = String(options.haServer ?? haServer)
89  ollamaUrl = String(options.ollamaUrl ?? ollamaUrl).replace(/\/+$/, '')
90  swimmers = Math.max(0, Number(options.swimmers ?? 24))
91
92  on('session.start', async ($, e, next) => {
93    const started = await next(e)
94    await $.command.register({ name: 'reef', description: 'Your homelab as an aquarium: containers as fish, the GPU as a heater, HA as a lighthouse', argumentHint: '[text]' })
95    $.clock.every(60_000, () => {
96      void (async () => {
97        if ((await $.ui.panes()).some(p => p.id === PANE)) await fill($)
98      })()
99    })
100    return started
101  })
102
103  on('command.run', { command: 'reef' }, async ($, e) => {
104    if (/\btext\b/.test(e.args)) return { text: await fill($) }
105    if ((await $.ui.panes()).some(p => p.id === PANE)) {
106      await $.ui.close({ id: PANE })
107      return { text: 'Reef closed.' }
108    }
109    await $.ui.open({ id: PANE, title: TITLE })
110    $.clock.after(0, () => void fill($))
111    return { text: 'Reef opened; filling the tank.' }
112  })
113
114  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
115    const ui = $.ui.resolve(e)
116    const { Box, Text, Button } = ui
117    const t = await read($, tank)
118    const refresh = <Button key="refresh" label="Refresh" plain onPress={() => void fill($)} />
119    if (e.surface === 'desktop' && 'Svg' in ui) {
120      const { Svg } = ui
121      const W = widthFor(e.props.bodyColumns, 280, 1000)
122      const H = 260
123      const body = scene(W, H, t, swimmers, (x, y, i) => crab(x, y, ROTATION[i % ROTATION.length] ?? 'other', 'working', 1))
124      const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${THEME_CSS}${CRAB_CSS}${body}</svg>`
125      return (
126        <Box flexDirection="column">
127          <Svg key="tank" source={source} alt={t.at ? summary(t) : 'The tank is filling.'} width={W} height={H} isInteractive />
128          <Box flexDirection="row" gap={2}>
129            <Text dimColor wrap="truncate-end">
130              {t.at ? summary(t) : 'Filling the tank…'}
131            </Text>
132            {refresh}
133          </Box>
134          {t.errors.length ? <Text dimColor>{`couldn't read ${t.errors.join('; ')}`}</Text> : null}
135        </Box>
136      )
137    }
138    const byStack = STACKS.map(([name]) => [name, t.fish.filter(f => f.stack === name && f.mood === 'swim').length] as const).filter(([, n]) => n)
139    return (
140      <Box flexDirection="column">
141        <Text>{t.at ? summary(t) : 'Filling the tank…'}</Text>
142        {byStack.length ? <Text dimColor>{byStack.map(([n, c]) => `${n} ${c}`).join(' · ')}</Text> : null}
143        {t.fish
144          .filter(f => f.mood === 'bellyup' || f.mood === 'missing')
145          .map(f => (
146            <Text key={f.name} color="red">
147              {'  '}✗ {f.name}: {f.status ?? f.mood}
148            </Text>
149          ))}
150        {refresh}
151      </Box>
152    )
153  })
154}
155
hooks/logic.ts 136 lines
1// Pure logic for reef: containers into fish, and the scene as SVG.
2
3import type { Fish, Tank } from '../types'
4
5export const STACKS: [string, RegExp, string][] = [
6  ['arr', /(radarr|sonarr|prowlarr|lidarr|readarr|bazarr|seerr|overseerr|jellyseerr|prismarr|recyclarr|qbit|sabnzbd|nzbget|gluetun|flaresolverr|unpackerr)/i, '#e8833a'],
7  ['media', /(plex|jellyfin|emby|tautulli|kometa|audiobookshelf|navidrome|immich|photoprism)/i, '#9b6bd6'],
8  ['home', /(home-?assistant|esphome|mqtt|mosquitto|zigbee|z2m|zwave|node-?red|frigate|music-?assistant|matter|govee|theater-panel|kiosk)/i, '#21a38f'],
9  ['net', /(technitium|adguard|pihole|nginx|npm|proxy|tailscale|cloudflared|unifi|wireguard|traefik|caddy|ddns|dns)/i, '#3b82d6'],
10  ['data', /(postgres|mariadb|mysql|redis|influx|mongo|zerobyte|restic|duplicati|backup|minio|syncthing)/i, '#c9a227'],
11  ['ai', /(ollama|open-?webui|claude|codex|llm|whisper|piper|wyoming)/i, '#d95fa0'],
12]
13
14export function stackOf(name: string, image = ''): { stack: string; color: string } {
15  const hay = `${name} ${image}`
16  const hit = STACKS.find(([, re]) => re.test(hay))
17  return hit ? { stack: hit[0], color: hit[2] } : { stack: 'other', color: '#8a96a3' }
18}
19
20type Container = { names?: string[]; image?: string; state?: string; status?: string; autoStart?: boolean }
21
22export function fishFrom(containers: Container[]): Fish[] {
23  return containers
24    .filter(c => c && typeof c === 'object')
25    .map(c => {
26      const name = (c.names?.[0] ?? '?').replace(/^\//, '')
27      const running = c.state === 'RUNNING'
28      const mood: Fish['mood'] = running ? (/\(unhealthy\)/i.test(c.status ?? '') ? 'bellyup' : 'swim') : c.autoStart ? 'missing' : 'rest'
29      return { name, stack: stackOf(name, c.image).stack, mood, ...(c.image ? { image: c.image } : {}), ...(c.status ? { status: c.status } : {}) }
30    })
31    .sort((a, b) => a.name.localeCompare(b.name))
32}
33
34/** A small, stable hash: the same fish keeps its place and pace. */
35export function hash(s: string): number {
36  let h = 2166136261
37  for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0
38  return h
39}
40
41export function summary(t: Tank): string {
42  const n = (m: Fish['mood']) => t.fish.filter(f => f.mood === m)
43  const parts = [`${n('swim').length} fish swimming`]
44  if (n('bellyup').length) parts.push(`belly-up: ${n('bellyup').map(f => f.name).join(', ')}`)
45  if (n('missing').length) parts.push(`missing: ${n('missing').map(f => f.name).join(', ')}`)
46  if (n('rest').length) parts.push(`${n('rest').length} resting`)
47  parts.push(t.models.length ? `heater on (${t.models.join(', ')})` : 'heater off')
48  if (t.ha) parts.push(t.ha.isRunning ? (t.ha.repairs ? `lighthouse blinking: ${t.ha.repairs} HA repairs` : 'lighthouse steady') : 'lighthouse dark: HA not running')
49  if (t.crabs.length) parts.push(`${t.crabs.length} crab${t.crabs.length === 1 ? '' : 's'} at work`)
50  return parts.join(' · ')
51}
52
53const esc = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
54
55/** One fish at (x, y) facing right, `len` long; the body and fins in its stack colour. */
56export function fishShape(f: Fish, len: number): string {
57  const color = stackOf(f.name, f.image).color
58  const h = len * 0.42
59  const body = f.mood === 'bellyup' || f.mood === 'missing' ? '#9aa3ab' : color
60  return (
61    `<ellipse cx="${len * 0.55}" cy="0" rx="${len * 0.45}" ry="${h / 2}" fill="${body}"/>` +
62    `<path d="M${len * 0.14} 0 L0 ${-h * 0.45} L0 ${h * 0.45}Z" fill="${body}" opacity="0.85"/>` +
63    `<path d="M${len * 0.5} ${-h / 2.2} q${len * 0.12} ${-h * 0.35} ${len * 0.22} 0Z" fill="${body}" opacity="0.7"/>` +
64    (f.mood === 'bellyup' || f.mood === 'missing'
65      ? `<path d="M${len * 0.78} ${-h * 0.14} l${len * 0.07} ${h * 0.14} m0 ${-h * 0.14} l${-len * 0.07} ${h * 0.14}" stroke="#2b2b2b" stroke-width="1"/>`
66      : `<circle cx="${len * 0.8}" cy="${-h * 0.08}" r="${Math.max(1.2, len * 0.045)}" fill="#1b1b1b"/>`)
67  )
68}
69
70/** The tank: water, sand, plants, heater, lighthouse, fish, then crabs drawn by `crabAt`. */
71export function scene(W: number, H: number, t: Tank, swimmers: number, crabAt: (x: number, y: number, i: number) => string): string {
72  const sandY = H - 34
73  const css: string[] = [
74    '.water{fill:url(#w)}.sand{fill:#e7d7b0}.sand2{fill:#d9c596}.weed{fill:none;stroke:#3f9a6b;stroke-width:3;stroke-linecap:round;animation:sway 4s ease-in-out infinite;transform-box:fill-box;transform-origin:50% 100%}',
75    '@keyframes sway{50%{transform:rotate(6deg)}}.bub{fill:#ffffff;opacity:.5;animation:rise 6s linear infinite}@keyframes rise{to{transform:translateY(-220px);opacity:0}}',
76    '.heat{animation:glow 2.4s ease-in-out infinite}@keyframes glow{50%{opacity:.45}}.beam{animation:beam 3s steps(1) infinite}@keyframes beam{50%{opacity:0}}',
77    '.bob{animation:bob 3s ease-in-out infinite;transform-box:fill-box}@keyframes bob{50%{transform:translateY(-3px)}}',
78    '.tl{fill:#e9f4fb;font-size:9px}',
79    '@media (prefers-color-scheme: dark){.sand{fill:#5a5038}.sand2{fill:#4a4230}}',
80  ]
81  const out: string[] = [
82    `<defs><linearGradient id="w" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7cc3e8"/><stop offset="1" stop-color="#1f6a9a"/></linearGradient></defs>`,
83    `<rect class="water" x="0" y="0" width="${W}" height="${H}" rx="8"/>`,
84  ]
85  // Weeds and bubbles: fixed by width, so a redraw doesn't shuffle them.
86  for (let i = 0; i < Math.max(3, Math.floor(W / 120)); i++) {
87    const x = 30 + ((i * 137) % Math.max(60, W - 60))
88    out.push(`<path class="weed" style="animation-delay:-${i * 0.7}s" d="M${x} ${sandY + 6} q-8 -20 0 -40 q8 -20 0 -40"/>`)
89    out.push(`<circle class="bub" style="animation-delay:-${(i * 1.3) % 6}s" cx="${x + 14}" cy="${sandY}" r="2.5"/>`)
90  }
91  out.push(`<path class="sand" d="M0 ${sandY} q${W / 4} -8 ${W / 2} 0 t${W / 2} 0 V${H} H0Z"/><rect class="sand2" x="0" y="${H - 10}" width="${W}" height="10"/>`)
92  // Heater, bottom left: glows while a model is loaded on the GPU.
93  const on = t.models.length > 0
94  out.push(
95    `<g><title>${esc(on ? `GPU heater: ${t.models.join(', ')}` : 'GPU heater: no model loaded')}</title><rect x="10" y="${sandY - 64}" width="12" height="62" rx="5" fill="#5b636b"/>` +
96      `<rect ${on ? 'class="heat" ' : ''}x="12" y="${sandY - 58}" width="8" height="50" rx="3" fill="${on ? '#ff8a3d' : '#3b4248'}"/></g>`,
97  )
98  // Lighthouse, bottom right: HA.
99  if (t.ha) {
100    const lx = W - 34
101    const lamp = !t.ha.isRunning ? '#3b3b3b' : t.ha.repairs ? '#ff5a4a' : '#ffe08a'
102    out.push(
103      `<g><title>${esc(t.ha.isRunning ? `Home Assistant: ${t.ha.repairs} repairs, ${t.ha.notes} notifications` : 'Home Assistant is not running')}</title>` +
104        `<path d="M${lx - 8} ${sandY + 2} L${lx - 5} ${sandY - 60} H${lx + 5} L${lx + 8} ${sandY + 2}Z" fill="#f4f1ea"/>` +
105        `<rect x="${lx - 5}" y="${sandY - 44}" width="10" height="7" fill="#d2463a"/><rect x="${lx - 5}" y="${sandY - 22}" width="10" height="7" fill="#d2463a"/>` +
106        `<rect x="${lx - 6}" y="${sandY - 72}" width="12" height="12" fill="${lamp}"/>` +
107        (t.ha.isRunning && t.ha.repairs ? `<path class="beam" d="M${lx - 6} ${sandY - 66} L${lx - 70} ${sandY - 84} L${lx - 70} ${sandY - 48}Z" fill="${lamp}" opacity="0.35"/>` : '') +
108        `<path d="M${lx - 8} ${sandY - 72} L${lx} ${sandY - 80} L${lx + 8} ${sandY - 72}Z" fill="#2f3a44"/></g>`,
109    )
110  }
111  // Fish: swimmers get a lane each; the rest hover in place.
112  const live = t.fish.filter(f => f.mood !== 'rest')
113  const top = 16
114  const lanes = Math.max(1, sandY - 40 - top)
115  live.forEach((f, i) => {
116    const h = hash(f.name)
117    const len = 16 + (h % 9)
118    const y = f.mood === 'bellyup' || f.mood === 'missing' ? top + (h % 18) : top + 22 + (h % lanes)
119    const x0 = 36 + ((h >>> 8) % Math.max(40, W - 120))
120    const isSwimmer = f.mood === 'swim' && i < swimmers
121    const d = Math.min(W - 70 - x0, 60 + ((h >>> 4) % 140))
122    const dur = 7 + ((h >>> 12) % 9)
123    const flip = f.mood === 'bellyup' || f.mood === 'missing' ? ` scale(1,-1)` : ''
124    const label = `${f.name}${f.image ? ` · ${f.image}` : ''}${f.status ? ` · ${f.status}` : ''}`
125    if (isSwimmer && d > 20) {
126      css.push(`@keyframes f${i}{0%{transform:translate(${x0}px,${y}px) scaleX(1)}48%{transform:translate(${x0 + d}px,${y}px) scaleX(1)}50%{transform:translate(${x0 + d + len}px,${y}px) scaleX(-1)}98%{transform:translate(${x0 + len}px,${y}px) scaleX(-1)}100%{transform:translate(${x0}px,${y}px) scaleX(1)}}`)
127      out.push(`<g style="animation:f${i} ${dur}s ease-in-out infinite;animation-delay:-${h % dur}s"><title>${esc(label)}</title>${fishShape(f, len)}</g>`)
128    } else {
129      out.push(`<g transform="translate(${x0},${y})${flip}"><g class="bob" style="animation-delay:-${h % 3}s"><title>${esc(label)}</title>${fishShape(f, len)}</g></g>`)
130    }
131  })
132  // Crabs on the sand, one per side session at work.
133  t.crabs.slice(0, Math.max(0, Math.floor((W - 120) / 44))).forEach((_, i) => out.push(crabAt(44 + i * 44, sandY - 20, i)))
134  return `<style>${css.join('\n')}</style>${out.join('')}`
135}
136
hooks/shared/crab.ts 230 lines
1// The crew's crabs: pixel art, costumes and motion copied from savvy-progress
2// (https://github.com/JohnnyVizz/claude-kit/tree/main/plugins/savvy-progress,
3// MIT License, Copyright (c) 2026 johnnyvizz; see THIRD_PARTY_NOTICES.md),
4// which credits the pixel crab to DockCrab (Clawdy).
5//
6// Changed here: a pose per session status (closed eyes and tucked legs to rest,
7// happy eyes, both claws up and a hop to celebrate), and costumes named for
8// what they wear instead of savvy-flow's tiers.
9
10/** A crab's pose, by what its owner is doing. */
11export type CrewStatus = 'working' | 'idle' | 'done' | 'proposed' | 'dismissed'
12
13// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
14// The body keeps the brand clay; the tier's color lives in the costume's accent.
15const CLAY = '#D97757'
16const INK = '#1F1E1D'
17
18export const COSTUME_COLOR: Record<string, string> = {
19  fable: '#7F77DD',
20  heavy: '#D85A30',
21  careful: '#BA7517',
22  medium: '#378ADD',
23  light: '#1D9E75',
24  explore: '#55514C',
25  other: '#888780',
26}
27
28/** What a `faces` rule or `/crew face` may name, to the costume it draws. */
29export const COSTUME_NAMES: Record<string, string> = {
30  astronaut: 'fable',
31  detective: 'heavy',
32  engineer: 'careful',
33  chef: 'medium',
34  racer: 'light',
35  pirate: 'explore',
36  plain: 'other',
37  // savvy-flow's own tier names work too.
38  fable: 'fable',
39  heavy: 'heavy',
40  careful: 'careful',
41  medium: 'medium',
42  light: 'light',
43  explore: 'explore',
44  other: 'other',
45}
46
47export const WEARS: Record<string, string> = {
48  fable: 'astronaut',
49  heavy: 'detective',
50  careful: 'engineer',
51  medium: 'chef',
52  light: 'racer',
53  explore: 'pirate',
54  other: 'plain',
55}
56
57/** Sessions with no rule take these in turn, in the order they joined. */
58export const ROTATION = ['fable', 'heavy', 'careful', 'medium', 'light', 'explore'] as const
59
60export const costumeKey = (name: string): string | undefined => COSTUME_NAMES[name.trim().toLowerCase()]
61
62// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
63// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
64type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
65
66type Pose = { eyes: 'open' | 'closed' | 'happy'; isCheering: boolean }
67
68const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
69  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
70
71const eyes = (f: Fill, pose: Pose): void => {
72  if (pose.eyes === 'closed') {
73    f(9, 13, 2, 1, INK)
74    f(19, 13, 2, 1, INK)
75  } else if (pose.eyes === 'happy') {
76    f(9, 13, 1, 1, INK); f(10, 12, 1, 1, INK)
77    f(19, 12, 1, 1, INK); f(20, 13, 1, 1, INK)
78  } else {
79    f(9, 12, 2, 2, INK)
80    f(19, 12, 2, 2, INK)
81  }
82}
83
84// `armCls` lets a raised claw travel with the prop it holds. A cheering crab
85// raises the back claw too, in `ar`, which waves.
86const crabBody = (f: Fill, pose: Pose, armFront = 0, armCls?: string): void => {
87  f(7, 10, 16, 12, CLAY)
88  if (pose.isCheering) f(3, 10, 4, 4, CLAY, 'ar')
89  else f(3, 14, 4, 4, CLAY)
90  f(23, 14 + (pose.isCheering && !armCls ? -4 : armFront), 4, 4, CLAY, armCls ?? (pose.isCheering ? 'ar' : undefined))
91  eyes(f, pose)
92  f(7, 22, 2, 4, CLAY, 'la')
93  f(17, 22, 2, 4, CLAY, 'la')
94  f(11, 22, 2, 4, CLAY, 'lb')
95  f(21, 22, 2, 4, CLAY, 'lb')
96}
97
98// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
99// once-a-second redraw of a running row restarts them in phase. Every crab walks;
100// each costume adds its prop's own motion on top.
101export const CRAB_CSS = `<style>
102.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
103.run .bd{animation:bob .5s steps(1) infinite -.125s}
104.run g{transform-box:fill-box}
105@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
106.c-fable.run{animation:float 1s ease-in-out infinite}
107.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
108.c-fable.run .ant{animation:blink 1s steps(1) infinite}
109.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
110@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
111.c-heavy.run .it{animation:scan 1s steps(1) infinite}
112.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
113@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
114.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
115@keyframes twist{50%{transform:rotate(-35deg)}}
116.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
117.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
118@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
119.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
120.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
121@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
122.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
123@keyframes fence{50%{transform:rotate(25deg)}}
124.rest g{transform-box:fill-box}
125.rest .bd{transform:translateY(2px)}.rest .la,.rest .lb{transform-origin:50% 100%;transform:scaleY(.5)}
126.rest{transform-box:fill-box;transform-origin:50% 100%;animation:breathe 4s ease-in-out infinite}
127@keyframes breathe{50%{transform:scaleY(.96)}}
128.cheer{animation:hop .6s ease-in-out infinite}.cheer g{transform-box:fill-box}
129.cheer .ar{animation:clap .3s steps(1) infinite}
130@keyframes hop{50%{transform:translateY(-3px)}}@keyframes clap{50%{transform:translateY(-1px)}}
131.z{opacity:0;animation:zz 3s linear infinite}.z2{animation-delay:1s}.z3{animation-delay:2s}
132@keyframes zz{0%{opacity:0;transform:translate(0,0)}25%{opacity:.9}100%{opacity:0;transform:translate(-5px,-12px)}}
133.cf{transform-box:fill-box;transform-origin:center;animation:fall 1.8s linear infinite}
134.cf1{animation-delay:-.4s}.cf2{animation-delay:-.9s}.cf3{animation-delay:-1.3s}
135@keyframes fall{0%{transform:translateY(-4px) rotate(0);opacity:1}100%{transform:translateY(28px) rotate(300deg);opacity:0}}
136.q{animation:blink 1.6s steps(1) infinite}
137@media (prefers-reduced-motion: reduce){.run,.run g,.rest,.cheer,.cheer g,.z,.cf,.q{animation:none!important}.z{opacity:.7}}
138</style>`
139
140const COSTUMES: Record<string, (f: Fill, t: string, pose: Pose) => void> = {
141  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
142  fable: (f, t, pose) => {
143    crabBody(f, pose)
144    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
145    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
146    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
147    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
148  },
149  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
150  heavy: (f, t, pose) => {
151    crabBody(f, pose, -4, 'it')
152    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
153    f(6, 9, 18, 1, t)
154    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
155    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
156  },
157  // Careful: engineer in a hard hat; the wrench turns a bolt.
158  careful: (f, t, pose) => {
159    crabBody(f, pose)
160    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
161    f(13, 5, 4, 2, t)
162    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
163  },
164  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
165  medium: (f, t, pose) => {
166    crabBody(f, pose, -4, 'pan')
167    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
168    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
169  },
170  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
171  light: (f, t, pose) => {
172    crabBody(f, pose, -4)
173    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
174    f(25, 1, 1, 9, '#8E929A')
175    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
176  },
177  // Explore: pirate scouting the code; the cutlass fences.
178  explore: (f, _t, pose) => {
179    crabBody(f, pose)
180    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
181    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
182    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
183  },
184  other: (f, _t, pose) => crabBody(f, pose),
185}
186
187const MOTION: Record<CrewStatus, string> = {
188  working: ' run',
189  idle: ' rest',
190  done: ' cheer',
191  proposed: '',
192  dismissed: '',
193}
194
195// What floats around the crab, in its own 30×28 grid: top left, the one corner no costume's prop takes.
196const EXTRAS: Partial<Record<CrewStatus, string>> = {
197  idle:
198    `<text class="z" x="0" y="8" font-family="sans-serif" font-size="6" font-weight="700" fill="#9a9a96">z</text>` +
199    `<text class="z z2" x="0" y="8" font-family="sans-serif" font-size="7" font-weight="700" fill="#9a9a96">z</text>` +
200    `<text class="z z3" x="0" y="8" font-family="sans-serif" font-size="9" font-weight="700" fill="#9a9a96">Z</text>`,
201  done: ['#D85A30', '#378ADD', '#F5C542', '#7F77DD'].map((c, i) => `<rect class="cf cf${i}" x="${2 + i * 8}" y="0" width="2" height="3" fill="${c}"/>`).join(''),
202  proposed: `<text class="q" x="0" y="9" font-family="sans-serif" font-size="9" font-weight="700" fill="#BA7517">?</text>`,
203}
204
205export const CRAB_SCALE = 1.1
206
207/**
208 * One crab at (x, y) in `costume`, posed for `status`. Body and props nest
209 * inside `bd` so a prop rides the bob and adds its own motion; legs stay
210 * outside it and step on their own.
211 */
212export function crab(x: number, y: number, costume: string, status: CrewStatus, scale = CRAB_SCALE): string {
213  const pose: Pose = {
214    eyes: status === 'idle' ? 'closed' : status === 'done' ? 'happy' : 'open',
215    isCheering: status === 'done',
216  }
217  const groups = new Map<string, string[]>([['bd', []]])
218  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
219    if (!groups.has(cls)) groups.set(cls, [])
220    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
221  }
222  const draw = COSTUMES[costume] ?? COSTUMES.other
223  draw?.(f, COSTUME_COLOR[costume] ?? '#888780', pose)
224  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
225  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
226  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
227  const dim = status === 'proposed' ? ' opacity="0.45"' : status === 'dismissed' ? ' opacity="0.3"' : ''
228  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g${dim}><g class="c-${costume}${MOTION[status]}">${body}${group('la')}${group('lb')}</g></g>${EXTRAS[status] ?? ''}</g>`
229}
230
hooks/shared/draw.ts 50 lines
1// Shared drawing helpers for the crab-workshop mods (copied into each mod's
2// hooks/shared/ by scripts/sync-shared.sh; edit the copy in /shared).
3
4import { CRAB_CSS } from './crab'
5
6export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
7export const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
8
9export const xml = (s: string): string =>
10  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
11
12/** Cuts `s` to what fits `maxW` pixels at `size`, with an ellipsis. */
13export const fit = (s: string, size: number, maxW: number): string => {
14  const max = Math.max(4, Math.floor(maxW / (size * 0.56)))
15  return s.length > max ? s.slice(0, max - 1) + '…' : s
16}
17
18// Text and line colours for both themes; `.ok`, `.warn`, `.bad` are state colours.
19export const THEME_CSS = `<style>
20.t{fill:#1f1f1f}.s{fill:#6b6b68}.ln{stroke:#e4e4e1}.ok{fill:#2f8a57}.warn{fill:#b07012}.bad{fill:#c2412d}
21.trk{fill:#ececea}.bar{fill:#378ADD}
22@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.ln{stroke:#333331}.ok{fill:#6fcf97}.warn{fill:#f0c46a}.bad{fill:#f28b75}.trk{fill:#2b2b29}.bar{fill:#6aaef0}}
23@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
24</style>`
25
26/** An SVG document with the theme and crab styles, `extraCss` (a <style> block) after them. */
27export const svg = (W: number, H: number, body: string, extraCss = ''): string =>
28  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${THEME_CSS}${CRAB_CSS}${extraCss}${body}</svg>`
29
30/** Width in CSS pixels for a pane or band `bodyColumns` wide. */
31export const widthFor = (bodyColumns: number | undefined, min = 240, max = 900): number =>
32  Math.max(min, Math.min(max, (bodyColumns || 40) * 8 - 8))
33
34/** A horizontal meter: track plus a bar `pct` full, coloured by thresholds. */
35export const meter = (x: number, y: number, w: number, h: number, pct: number, warnAt = 80, badAt = 95): string => {
36  const p = Math.max(0, Math.min(100, pct))
37  const cls = p >= badAt ? 'bad' : p >= warnAt ? 'warn' : 'bar'
38  return `<rect class="trk" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/><rect class="${cls}" x="${x}" y="${y}" width="${Math.max(h, (w * p) / 100)}" height="${h}" rx="${h / 2}"/>`
39}
40
41/** "3m", "2h 5m", "4d" from milliseconds. */
42export const ago = (ms: number): string => {
43  const m = Math.round(ms / 60_000)
44  if (m < 1) return 'now'
45  if (m < 60) return `${m}m`
46  const h = Math.floor(m / 60)
47  if (h < 48) return m % 60 && h < 10 ? `${h}h ${m % 60}m` : `${h}h`
48  return `${Math.floor(h / 24)}d`
49}
50
types/index.d.ts 27 lines
1export type Fish = {
2  name: string
3  stack: string
4  /** swimming, belly-up (unhealthy), resting (stopped on purpose), missing (should run, doesn't). */
5  mood: 'swim' | 'bellyup' | 'rest' | 'missing'
6  image?: string
7  status?: string
8}
9
10export type Tank = {
11  fish: Fish[]
12  /** Loaded Ollama models, for the heater. */
13  models: string[]
14  /** HA: null when not read. */
15  ha: { repairs: number; notes: number; isRunning: boolean } | null
16  /** Side sessions at work, one crab each. */
17  crabs: string[]
18  at: number
19  errors: string[]
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    reef: { tank: Tank }
25  }
26}
27