Unraid and Home Assistant health above the prompt: array and ZFS pools, CPU and memory, the Docker service, the Unraid integration's problem sensors (shelves…

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.
| Mod | What you get | Commands | |
|---|---|---|---|
| where-am-i | Status 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-call | A 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-crab | While 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-strip | Unraid 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]` |
| reef | An 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] | |
| keymaster | Masks 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-guard | Refuses 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 | |
| gatekeeper | A 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-corner | A 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-rules | Notes, 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> |
Everything is read-only.
| Mod | Reads |
|---|---|
| roll-call, fuel-gauge, reef | The desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage) |
| vitals-strip | The 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 |
| reef | unraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview) |
| gpu-crab, reef | Ollama GET /api/ps at the ollamaUrl option (default http://localhost:11434) |
| gatekeeper | gh api graphql, one search query every 5 minutes and shortly after a push |
| where-am-i, trailer-guard, codex-corner | git 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"] } }
}
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.
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.
AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead./keymaster allow its tag.MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.
hooks/register.tsx 165 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Chip, ChipLevel, Vitals } from '../types'
5import { arrayChip, byLevel, HA_VITALS_TEMPLATE, haChip, haVitalsOf, metricsChip, notificationChip, parityChip, problemsChip, resultOf, shown, statusLine, worst } from './logic'
6import { crab } from './shared/crab'
7import { FONT, svg, widthFor, xml } from './shared/draw'
8
9const vitals = atom({ plugin: 'vitals-strip', key: 'vitals' } as const, { chips: [], at: 0, errors: [], isAlwaysShown: false })
10
11let haServer = ''
12let pollMs = 120_000
13let quiet = true
14let reading: Promise<string> | null = null
15
16const textOf = (r: { content: readonly unknown[] }): string =>
17 r.content.map(b => (b && typeof b === 'object' && 'text' in b && typeof b.text === 'string' ? b.text : '')).join('')
18
19/** One read-only MCP call, its JSON result, or why not. */
20async function ask(
21 $: EngineInterface,
22 server: string,
23 tool: string,
24 args: Record<string, unknown>,
25 parse: (text: string) => Record<string, unknown> | null = resultOf,
26): Promise<Record<string, unknown> | string> {
27 try {
28 const r = await $.mcp.call(server, tool, args)
29 const text = textOf(r)
30 if (r.isError) return text.slice(0, 120) || 'error'
31 return parse(text) ?? 'not JSON'
32 } catch (err) {
33 return (err instanceof Error ? err.message : String(err)).slice(0, 120)
34 }
35}
36
37function readVitals($: EngineInterface): Promise<string> {
38 if (!reading)
39 reading = readNow($).finally(() => {
40 reading = null
41 })
42 return reading
43}
44
45async function readNow($: EngineInterface): Promise<string> {
46 // Unraid comes from the Unraid Management Agent integration's entities in
47 // HA: one read-only template call, not five unraid-mcp calls (auto mode
48 // reviews each MCP call a mod makes, and the unraid tool can also write).
49 const [unraid, ha] = haServer
50 ? await Promise.all([
51 ask($, haServer, 'ha_eval_template', { template: HA_VITALS_TEMPLATE, report_errors: false, timeout: 10 }, haVitalsOf),
52 ask($, haServer, 'ha_get_overview', { fields: ['system_info', 'notification_count', 'repair_count'] }),
53 ])
54 : [null, null]
55 const part = (key: string) => (r: Record<string, unknown> | string | null): Record<string, unknown> | string | null =>
56 r === null || typeof r === 'string' ? r : Number(r.found ?? 0) === 0 ? 'no Unraid Management Agent entities in HA' : ((r[key] as Record<string, unknown> | undefined) ?? null)
57 const chips: Chip[] = []
58 const errors: string[] = []
59 const add = (name: string, r: Record<string, unknown> | string | null, make: (r: Record<string, unknown>) => Chip | null) => {
60 if (r === null) return
61 if (typeof r === 'string') errors.push(`${name}: ${r}`)
62 else {
63 const c = make(r)
64 if (c) chips.push(c)
65 }
66 }
67 add('unraid', part('array')(unraid), arrayChip)
68 if (unraid && typeof unraid !== 'string' && Number(unraid.found ?? 0) > 0) {
69 add('load', part('metrics')(unraid), metricsChip)
70 add('notifications', part('notes')(unraid), notificationChip)
71 add('parity', part('parity')(unraid), parityChip)
72 add('problems', unraid, problemsChip)
73 }
74 add('home assistant', ha, haChip)
75 const at = await $.clock.now()
76 const prev = await read($, vitals)
77 const v = await update($, vitals, x => ({ ...x, chips, errors, at }))
78 $.ui.status(statusLine(chips, errors))
79 // A toast when something turns red that wasn't.
80 const wasBad = new Set(prev.chips.filter(c => c.level === 'bad').map(c => c.key))
81 for (const c of chips) if (c.level === 'bad' && !wasBad.has(c.key) && prev.at) $.ui.toast(`Vitals: ${c.text}`)
82 return asText(v)
83}
84
85function asText(v: Vitals): string {
86 const lines = byLevel(v.chips).map(c => `${MARK[c.level]} ${c.detail ?? c.text}`)
87 if (v.errors.length) lines.push(...v.errors.map(e => `? couldn't read ${e}`))
88 return lines.join('\n') || 'No vitals yet.'
89}
90
91const MARK: Record<ChipLevel, string> = { ok: '✓', info: 'i', warn: '!', bad: '✗' }
92const COLOR: Record<ChipLevel, string> = { ok: 'green', info: 'blue', warn: 'yellow', bad: 'red' }
93
94// ---------------------------------------------------------------------------
95// Drawing: pills in a row, a sysadmin crab at the left that sweats on red.
96
97const PILL_CSS = `<style>.p-ok{fill:#e3f3e9}.p-info{fill:#e4eefb}.p-warn{fill:#fbefd6}.p-bad{fill:#fbe0da}
98.pt-ok{fill:#1f6b43}.pt-info{fill:#1f4f8a}.pt-warn{fill:#7a5208}.pt-bad{fill:#9b2e1c}
99@media (prefers-color-scheme: dark){.p-ok{fill:#17382a}.p-info{fill:#18304d}.p-warn{fill:#3d3014}.p-bad{fill:#47201a}
100.pt-ok{fill:#8fe0b2}.pt-info{fill:#9cc6f5}.pt-warn{fill:#f3cf7e}.pt-bad{fill:#ffb3a3}}</style>`
101
102function stripSvg(W: number, chips: Chip[]): { source: string; height: number } {
103 let x = 44
104 let y = 6
105 const pills: string[] = []
106 for (const c of chips) {
107 const w = Math.round(c.text.length * 6.3 + 18)
108 if (x + w > W && x > 44) {
109 x = 44
110 y += 24
111 }
112 pills.push(`<rect class="p-${c.level}" x="${x}" y="${y}" width="${w}" height="20" rx="10"/><text class="pt-${c.level}" x="${x + 9}" y="${y + 14}" font-family="${FONT}" font-size="11" font-weight="600">${xml(c.text)}</text>`)
113 x += w + 6
114 }
115 const height = Math.max(36, y + 26)
116 const level = worst(chips)
117 return { source: svg(W, height, `${crab(0, 2, 'careful', level === 'bad' ? 'working' : 'idle', 1)}${pills.join('')}`, PILL_CSS), height }
118}
119
120export const register: Register = (on, options) => {
121 haServer = String(options.haServer ?? haServer)
122 pollMs = Math.max(0, Number(options.pollSeconds ?? 120)) * 1000
123 quiet = options.quiet !== false
124
125 on('session.start', async ($, e, next) => {
126 const started = await next(e)
127 await $.command.register({ name: 'vitals', description: 'Unraid and Home Assistant health: the full strip, read now', argumentHint: '[show|quiet|text]' })
128 // The MCP servers may still be connecting at start.
129 $.clock.after(8000, () => void readVitals($))
130 if (pollMs > 0) $.clock.every(Math.max(30_000, pollMs), () => void readVitals($))
131 return started
132 })
133
134 on('command.run', { command: 'vitals' }, async ($, e) => {
135 const w = e.args.trim().toLowerCase()
136 if (w === 'show' || w === 'quiet') {
137 await update($, vitals, v => ({ ...v, isAlwaysShown: w === 'show' }))
138 return { text: w === 'show' ? 'Vitals band: always shown.' : 'Vitals band: only when something needs a look.' }
139 }
140 return { text: await readVitals($) }
141 })
142
143 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
144 const v = await read($, vitals)
145 const chips = shown(v.chips, quiet && !v.isAlwaysShown)
146 if (e.props.hasSurvey || !chips.length) return next(e)
147 const ui = $.ui.resolve(e)
148 const { Box, Text } = ui
149 if (e.surface === 'desktop' && 'Svg' in ui) {
150 const { Svg } = ui
151 const d = stripSvg(widthFor(e.props.bodyColumns), chips)
152 return <Svg key="vitals" source={d.source} alt={chips.map(c => c.text).join(' · ')} width={widthFor(e.props.bodyColumns)} height={d.height} />
153 }
154 return (
155 <Box flexDirection="row" flexWrap="wrap" gap={2}>
156 {chips.map(c => (
157 <Text key={c.key} color={COLOR[c.level]}>
158 {MARK[c.level]} {c.text}
159 </Text>
160 ))}
161 </Box>
162 )
163 })
164}
165hooks/logic.ts 169 lines1// Pure logic for vitals-strip: the unraid-mcp and HA-MCP answers into chips.
2
3import type { Chip, ChipLevel } from '../types'
4
5/** The `result` of a unraid-mcp answer (`{"result": {...}}`), or the object itself. */
6export function resultOf(text: string): Record<string, unknown> | null {
7 try {
8 const v = JSON.parse(text) as Record<string, unknown>
9 const r = (v && typeof v === 'object' && 'result' in v ? v.result : v) as Record<string, unknown>
10 return r && typeof r === 'object' ? r : null
11 } catch {
12 return null
13 }
14}
15
16type ArraySummary = { state?: string; overall_health?: string; num_cache_pools?: number; num_data_disks?: number; health_summary?: Record<string, Record<string, number>> }
17
18export function arrayChip(r: Record<string, unknown>): Chip {
19 const s = (r.summary ?? r) as ArraySummary
20 const pools = s.num_cache_pools ?? 0
21 const sums = Object.values(s.health_summary ?? {})
22 const failed = sums.reduce((n, h) => n + (h.failed ?? 0) + (h.missing ?? 0), 0)
23 const warned = sums.reduce((n, h) => n + (h.warning ?? 0) + (h.critical ?? 0), 0)
24 const started = s.state === 'STARTED'
25 const healthy = s.overall_health === 'HEALTHY'
26 const level: ChipLevel = !started || failed ? 'bad' : !healthy || warned ? 'warn' : 'ok'
27 const what = [s.num_data_disks ? `${s.num_data_disks} disks` : '', pools ? `${pools} pools` : ''].filter(Boolean).join(', ')
28 const text = level === 'ok' ? `array ok${what ? ` · ${what}` : ''}` : `array ${(s.state ?? '?').toLowerCase()} · ${(s.overall_health ?? '?').toLowerCase()}${failed ? ` · ${failed} failed/missing` : ''}${warned ? ` · ${warned} warning` : ''}`
29 return { key: 'array', level, text, detail: `Array ${s.state ?? '?'}, health ${s.overall_health ?? '?'}${what ? ` (${what})` : ''}` }
30}
31
32export function metricsChip(r: Record<string, unknown>): Chip {
33 const cpu = Number((r.cpu as { percentTotal?: number } | undefined)?.percentTotal ?? NaN)
34 const mem = Number((r.memory as { percentTotal?: number } | undefined)?.percentTotal ?? NaN)
35 const level: ChipLevel = mem >= 95 || cpu >= 95 ? 'bad' : mem >= 90 || cpu >= 85 ? 'warn' : 'ok'
36 const text = `cpu ${Number.isFinite(cpu) ? Math.round(cpu) : '?'}% · mem ${Number.isFinite(mem) ? Math.round(mem) : '?'}%`
37 return { key: 'load', level, text, detail: text }
38}
39
40type Container = { names?: string[]; state?: string; status?: string; autoStart?: boolean }
41
42export function dockerChip(r: Record<string, unknown>): Chip {
43 const list = ((r.containers as Container[] | undefined) ?? []).filter(c => c && typeof c === 'object')
44 const name = (c: Container) => (c.names?.[0] ?? '?').replace(/^\//, '')
45 const running = list.filter(c => c.state === 'RUNNING')
46 const unhealthy = running.filter(c => /\(unhealthy\)/i.test(c.status ?? ''))
47 const starting = running.filter(c => /\(health: starting\)/i.test(c.status ?? ''))
48 const down = list.filter(c => c.autoStart && c.state !== 'RUNNING')
49 const restarting = list.filter(c => /restarting/i.test(`${c.state} ${c.status}`))
50 const level: ChipLevel = unhealthy.length || down.length || restarting.length ? 'bad' : starting.length ? 'info' : 'ok'
51 const parts = [`🐳 ${running.length}/${list.length}`]
52 if (unhealthy.length) parts.push(`unhealthy: ${unhealthy.map(name).slice(0, 3).join(', ')}${unhealthy.length > 3 ? ` +${unhealthy.length - 3}` : ''}`)
53 if (down.length) parts.push(`down: ${down.map(name).slice(0, 3).join(', ')}${down.length > 3 ? ` +${down.length - 3}` : ''}`)
54 if (restarting.length) parts.push(`restarting: ${restarting.map(name).slice(0, 2).join(', ')}`)
55 return {
56 key: 'docker',
57 level,
58 text: parts.join(' · '),
59 detail: `Containers: ${running.length} running of ${list.length}${unhealthy.length ? `; unhealthy: ${unhealthy.map(name).join(', ')}` : ''}${down.length ? `; set to autostart but not running: ${down.map(name).join(', ')}` : ''}${starting.length ? `; starting: ${starting.map(name).join(', ')}` : ''}`,
60 }
61}
62
63export function notificationChip(r: Record<string, unknown>): Chip {
64 const u = (r.unread ?? {}) as { info?: number; warning?: number; alert?: number; total?: number }
65 const level: ChipLevel = (u.alert ?? 0) > 0 ? 'bad' : (u.warning ?? 0) > 0 ? 'warn' : (u.total ?? 0) > 0 ? 'info' : 'ok'
66 const parts = [u.alert ? `${u.alert} alert` : '', u.warning ? `${u.warning} warning` : '', u.info ? `${u.info} info` : ''].filter(Boolean)
67 return { key: 'notes', level, text: parts.length ? `🔔 ${parts.join(', ')}` : '🔔 none', detail: `Unraid unread notifications: ${parts.join(', ') || 'none'}` }
68}
69
70export function parityChip(r: Record<string, unknown>): Chip | null {
71 const d = (r.data ?? r) as { status?: string; progress?: number; running?: boolean | null; errors?: number | null; speed?: string }
72 if (!d.running && !(d.errors ?? 0)) return null
73 const level: ChipLevel = (d.errors ?? 0) > 0 ? 'bad' : 'info'
74 const text = d.running ? `parity ${Math.round(d.progress ?? 0)}%${d.errors ? ` · ${d.errors} errors` : ''}` : `parity: ${d.errors} errors`
75 return { key: 'parity', level, text, detail: `Parity ${d.status ?? ''} ${text}` }
76}
77
78export function haChip(r: Record<string, unknown>): Chip {
79 const sys = (r.system_info ?? {}) as { version?: string; state?: string }
80 const repairs = Number(r.repair_count ?? 0)
81 const notes = Number(r.notification_count ?? 0)
82 const running = (sys.state ?? 'RUNNING') === 'RUNNING'
83 const level: ChipLevel = !running ? 'bad' : repairs ? 'warn' : notes ? 'info' : 'ok'
84 const parts = [running ? `🏠 HA ${sys.version ?? ''}`.trim() : `🏠 HA ${(sys.state ?? '?').toLowerCase()}`]
85 if (repairs) parts.push(`${repairs} repair${repairs === 1 ? '' : 's'}`)
86 if (notes) parts.push(`${notes} notification${notes === 1 ? '' : 's'}`)
87 return { key: 'ha', level, text: parts.join(' · '), detail: `Home Assistant ${sys.version ?? '?'} ${sys.state ?? '?'}: ${repairs} repairs, ${notes} notifications` }
88}
89
90/**
91 * One HA template that reads the Unraid Management Agent integration's
92 * entities into the shapes the chips above take, plus the integration's
93 * problem sensors. Read-only, one call, instead of five unraid-mcp calls.
94 */
95export const HA_VITALS_TEMPLATE = `{%- set ents = integration_entities('unraid_management_agent') -%}
96{%- set one = namespace(cpu=none, ram=none, arr=none, par=none, info=none, warn=none, alert=none, docker=none) -%}
97{%- for e in ents -%}
98{%- if e is match('sensor\\..*unraid_cpu_usage$') -%}{%- set one.cpu = e -%}
99{%- elif e is match('sensor\\..*unraid_ram_usage$') -%}{%- set one.ram = e -%}
100{%- elif e is match('binary_sensor\\..*unraid_array_started$') -%}{%- set one.arr = e -%}
101{%- elif e is match('sensor\\..*unraid_parity_check_progress$') -%}{%- set one.par = e -%}
102{%- elif e is match('sensor\\..*unraid_notifications_unread_info$') -%}{%- set one.info = e -%}
103{%- elif e is match('sensor\\..*unraid_notifications_unread_warning$') -%}{%- set one.warn = e -%}
104{%- elif e is match('sensor\\..*unraid_notifications_unread_alert$') -%}{%- set one.alert = e -%}
105{%- elif e is match('binary_sensor\\..*unraid_docker_service$') -%}{%- set one.docker = e -%}
106{%- endif -%}
107{%- endfor -%}
108{%- set pools = ents | select('match', 'sensor\\..*unraid_zfs_pool_.*_health$') | list -%}
109{%- set health = pools | map('states') | list -%}
110{%- set failed = health | select('in', ['FAULTED', 'UNAVAIL', 'REMOVED', 'OFFLINE']) | list | count -%}
111{%- set degraded = health | reject('in', ['ONLINE', 'FAULTED', 'UNAVAIL', 'REMOVED', 'OFFLINE', 'unknown', 'unavailable']) | list | count -%}
112{%- set probs = namespace(l=[]) -%}
113{%- for e in ents if e.startswith('binary_sensor.') and is_state(e, 'on') and state_attr(e, 'device_class') == 'problem' -%}
114{%- set probs.l = probs.l + [state_attr(e, 'friendly_name') or e] -%}
115{%- endfor -%}
116{%- set p = states(one.par) | float(0) if one.par else 0 -%}
117{%- set i = states(one.info) | int(0) if one.info else 0 -%}
118{%- set w = states(one.warn) | int(0) if one.warn else 0 -%}
119{%- set a = states(one.alert) | int(0) if one.alert else 0 -%}
120{{ {
121 'found': ents | count,
122 'array': {'summary': {'state': ('STARTED' if is_state(one.arr, 'on') else 'STOPPED') if one.arr else none,
123 'overall_health': 'HEALTHY' if failed + degraded == 0 else 'DEGRADED',
124 'num_cache_pools': pools | count,
125 'health_summary': {'pools': {'failed': failed, 'warning': degraded}}}},
126 'metrics': {'cpu': {'percentTotal': states(one.cpu) | float(none) if one.cpu else none},
127 'memory': {'percentTotal': states(one.ram) | float(none) if one.ram else none}},
128 'notes': {'unread': {'info': i, 'warning': w, 'alert': a, 'total': i + w + a}},
129 'parity': {'data': {'running': 0 < p < 100, 'progress': p, 'errors': 0}},
130 'docker': (is_state(one.docker, 'on') if one.docker else none),
131 'problems': probs.l
132} | to_json }}`
133
134/** The template's JSON: ha_eval_template answers `{result: "<json>"}` or the parsed object. */
135export function haVitalsOf(text: string): Record<string, unknown> | null {
136 try {
137 const v = JSON.parse(text) as Record<string, unknown>
138 if (v && typeof v === 'object' && v.success === false) return null
139 let r: unknown = v && typeof v === 'object' && 'result' in v ? v.result : v
140 if (typeof r === 'string') r = JSON.parse(r)
141 return r && typeof r === 'object' ? (r as Record<string, unknown>) : null
142 } catch {
143 return null
144 }
145}
146
147/** Docker service down, or problem sensors from the integration that are on (shelves, HBAs, optics, pools). */
148export function problemsChip(r: Record<string, unknown>): Chip | null {
149 const problems = (Array.isArray(r.problems) ? r.problems : []).map(String)
150 const dockerDown = r.docker === false
151 if (!problems.length && !dockerDown) return null
152 const parts = [dockerDown ? '🐳 docker down' : '', problems.length ? `⚠ ${problems.slice(0, 2).join(', ')}${problems.length > 2 ? ` +${problems.length - 2}` : ''}` : ''].filter(Boolean)
153 return { key: 'problems', level: 'bad', text: parts.join(' · '), detail: `${dockerDown ? 'Docker service is not running. ' : ''}${problems.length ? `Problem sensors on: ${problems.join(', ')}` : ''}`.trim() }
154}
155
156const RANK: Record<ChipLevel, number> = { bad: 0, warn: 1, info: 2, ok: 3 }
157export const byLevel = (chips: Chip[]): Chip[] => [...chips].sort((a, b) => RANK[a.level] - RANK[b.level])
158export const worst = (chips: Chip[]): ChipLevel => byLevel(chips)[0]?.level ?? 'ok'
159
160/** The chips the band shows: all of them, or (quiet) only the ones that aren't green or plain info. */
161export const shown = (chips: Chip[], quiet: boolean): Chip[] => byLevel(quiet ? chips.filter(c => c.level === 'warn' || c.level === 'bad') : chips)
162
163export function statusLine(chips: Chip[], errors: string[]): string | undefined {
164 if (!chips.length) return errors.length ? 'vitals: no data' : undefined
165 const w = worst(chips)
166 const n = chips.filter(c => c.level === 'warn' || c.level === 'bad').length
167 return w === 'ok' || w === 'info' ? 'vitals: all green' : `vitals: ${n} need${n === 1 ? 's' : ''} a look`
168}
169hooks/shared/crab.ts 230 lines1// 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}
230hooks/shared/draw.ts 50 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
50types/index.d.ts 27 lines1export type ChipLevel = 'ok' | 'info' | 'warn' | 'bad'
2
3export type Chip = {
4 key: string
5 level: ChipLevel
6 /** Short text for the band: `array ok · 47 pools`. */
7 text: string
8 /** The longer line /vitals prints. */
9 detail?: string
10}
11
12export type Vitals = {
13 chips: Chip[]
14 at: number
15 /** Sources that failed on the last read, with why. */
16 errors: string[]
17 isAlwaysShown: boolean
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'vitals-strip': {
23 vitals: Vitals
24 }
25 }
26}
27