A side pane for your Home Assistant's health: Vome's score out of 100 and what its check found, by severity with what to do, marked as Claude works on each…

A side pane with Vome's health score for your Home Assistant, out of 100, and everything its check found, kept beside the conversation while Claude works through it.

The pane never fixes anything itself: Claude does, with its own tool calls, which you approve as usual. r only asks Vome for a fresh check.
1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.
The button opens your own Home Assistant and adds the app's repository. Pick Vome and Install, open it, and on the Agent tab create a key.
2. Paste these into Claude Code, in a terminal (version 2.1.275 or newer):
/plugin install vome-connect --marketplace Vortitron/home-assistant-mcp
It asks for the key from step 1. Then:
/plugin install vome-health --marketplace Vortitron/home-assistant-mcp
/reload-plugins
Or all four Vome panes at once (automations, ESPHome, health and a working dashboard): /plugin install vome-panes --marketplace Vortitron/home-assistant-mcp.
3. Ask Claude "How healthy is my Home Assistant? Fix what you can." The pane opens with the score. No score yet? Claude runs the first check, which takes a couple of minutes.
The pane reads the report in the background while a check runs, with the MCP's read-only vome_health_report; r calls vome_health_check. In auto mode, Claude Code refuses background calls that no request of yours is behind unless they're allowed by name, and the pane then shows the exact line. With vome-connect:
{ "permissions": { "allow": ["mcp__plugin_vome-connect_vome__vome_health_report"] } }
/health | open the pane and read the latest report |
| r | run a fresh check |
| d | each finding's evidence, not just its title |
| b | the animation off or on (remembered) |
The report is Vome's health check of your own Home Assistant, read from the sensor the Vome integration keeps it on (sensor.vome_health_score or similar). The pane reads it through the connection Claude Code already has; nothing is sent anywhere else.
hooks/register.tsx 591 lines1// A side pane for the home's health: Vome's score out of 100 and what its
2// check found, kept beside the conversation while Claude works through it.
3//
4// It fills when Claude reads the report (the MCP's vome_health_report) or with
5// /health. Each finding is listed by severity with what to do about it; when
6// Claude changes something a finding is about (one of its entities, the
7// recorder for a flooding sensor, an automation for the automation findings),
8// the finding is marked as worked on. A re-check (Claude's vome_health_check,
9// or r here) then settles it: resolved findings are struck through and the
10// score rolls to the new one, with fireworks when it went up. It never fixes
11// anything itself; r only asks for a fresh check.
12
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { Category, Change, Finding, Report } from '../types'
16import { STRIP_ROWS, stageFrame } from './stage'
17import type { StageState } from './stage'
18
19const PANE = 'vome-health'
20const README_URL = 'https://github.com/Vortitron/home-assistant-mcp/tree/main/claude-plugin/vome-health'
21/** How often a running check is looked in on: it takes a couple of minutes at Vome. */
22const POLL_MS = 10_000
23const CHECK_GIVE_UP_MS = 8 * 60_000
24/** How long the latest check's changes stay at the top. */
25const CHANGE_SHOWN_MS = 5 * 60_000
26const FRAME_MS = 80
27const IDLE_EVERY = 3
28const SEVERITY_ORDER = ['warn', 'advice', 'info']
29
30const report = { plugin: 'vome-health', key: 'report' } as const
31const change = { plugin: 'vome-health', key: 'change' } as const
32const checking = { plugin: 'vome-health', key: 'checking' } as const
33const touched = { plugin: 'vome-health', key: 'touched' } as const
34const details = { plugin: 'vome-health', key: 'details' } as const
35const fxOn = { plugin: 'vome-health', key: 'fxOn' } as const
36const blocked = { plugin: 'vome-health', key: 'blocked' } as const
37const note = { plugin: 'vome-health', key: 'note' } as const
38const asked = { plugin: 'vome-health', key: 'asked' } as const
39
40// Module variables reset on a reload, which is all these need.
41let isPolling = false
42let settingsFile = '~/.claude/settings.json'
43let strip: { columns: number; state: StageState } | null = null
44let isFxOn = true
45let isBlitting = false
46let frame = 0
47/** The server the last report (or the lack of one) came from, for a first check from the empty pane. */
48let lastServer: string | null = null
49
50export const register: Register = on => {
51 on('session.start', async ($, e, next) => {
52 await $.command.register({ name: 'health', description: "Show the home's Vome health score in a side pane" })
53 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
54 settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
55 $.clock.every(POLL_MS, () => void poll($))
56 $.clock.every(FRAME_MS, () => void animate($))
57 isFxOn = (await storeGet($, 'fxOn')) !== false
58 await $.state.set(fxOn, isFxOn)
59 return next(e)
60 })
61
62 on('command.run', { command: 'health' }, async $ => {
63 const opened = await $.ui.open({ id: PANE, title: 'Health' })
64 const known = (await $.state.get(report)).value ?? null
65 const server = known?.server ?? (await healthServers($))[0]
66 if (!server) {
67 // Connected, but to a server that predates the tool: Claude Code keeps the tool list it read at connect.
68 const older = (await $.tool.list()).map(tool => /^mcp__(.+)__ha_get_state$/.exec(tool.name)?.[1]).find(Boolean)
69 return {
70 text: older
71 ? `${older} has no vome_health_report in the tool list Claude Code read when it connected. If the server was updated since, run /mcp, choose ${older}, then Reconnect.`
72 : 'No connected MCP server has vome_health_report yet: connect Home Assistant through Vome first (vome-connect).',
73 }
74 }
75 void fetchReport($, server)
76 return { text: opened.isPlaced ? "Reading the home's health report into the pane." : `The health pane is not drawn here: ${opened.reason}.` }
77 })
78
79 on('tool.call', async ($, e, next) => {
80 const match = /^mcp__(.+)__([a-z_]+)$/.exec(e.tool)
81 if (!match?.[1] || !match[2]) return next(e)
82 const server = match[1]
83 const name = match[2]
84
85 if (name === 'vome_health_report') {
86 const ran = await next(e)
87 if (ran.deny === undefined && !ran.isError) {
88 await applyReport($, server, ran.text ?? '')
89 await $.ui.open({ id: PANE, title: 'Health' }).catch(() => undefined)
90 }
91 return ran
92 }
93 if (name === 'vome_health_check') {
94 const ran = await next(e)
95 if (ran.deny === undefined && !ran.isError) await startChecking($, server)
96 return ran
97 }
98
99 // Anything else that changes the home: is it about one of the findings?
100 const ran = await next(e)
101 if (ran.deny !== undefined || ran.isError || isRead(name)) return ran
102 const current = (await $.state.get(report)).value ?? null
103 if (!current) return ran
104 const text = JSON.stringify(e)
105 const hit = current.findings.filter(finding => touches(finding, name, text))
106 if (hit.length > 0) {
107 const before = (await $.state.get(touched)).value ?? {}
108 await $.state.set(touched, { ...before, ...Object.fromEntries(hit.map(finding => [finding.id, Date.now()])) })
109 }
110 return ran
111 })
112
113 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
114 const { Box, Text, Button, Code, Link, Markdown } = $.ui.resolve(e)
115 const current = (await $.state.get(report)).value ?? null
116 const latest = (await $.state.get(change)).value ?? null
117 const running = (await $.state.get(checking)).value ?? null
118 const worked = (await $.state.get(touched)).value ?? {}
119 const handed = (await $.state.get(asked)).value ?? {}
120 /** Hand findings to Claude: it fixes them with its own tools, which the person approves as usual. */
121 const fix = async (findings: Finding[]) => {
122 if (findings.length === 0) return
123 await $.prompt.submit({ text: fixPrompt(findings, current ? homeName(current) : 'the home') })
124 await $.state.set(asked, { ...handed, ...Object.fromEntries(findings.map(f => [f.id, Date.now()])) })
125 }
126 const isDetailed = (await $.state.get(details)).value ?? false
127 const isFxEnabled = (await $.state.get(fxOn)).value ?? true
128 const refusal = (await $.state.get(blocked)).value ?? null
129 const message = (await $.state.get(note)).value ?? null
130 const width = Math.max(20, e.props.bodyColumns)
131 const isChangeShown = latest !== null && Date.now() - latest.at < CHANGE_SHOWN_MS
132
133 let stage = null
134 if (e.surface === 'terminal' && isFxEnabled) {
135 const { Raster } = $.ui.resolve(e)
136 const columns = Math.min(512, width)
137 const home = current ? homeName(current) : ''
138 const words = running
139 ? `checking ${home || 'the home'}…`
140 : current
141 ? isChangeShown && latest.from !== null && latest.to !== null && latest.from !== latest.to
142 ? `${home} · ${latest.from} → ${latest.to}`
143 : `${home} · ${current.findings.length} finding${current.findings.length === 1 ? '' : 's'}`
144 : 'no health score yet'
145 strip = {
146 columns,
147 state: {
148 score: current?.score ?? null,
149 from: isChangeShown ? latest.from : null,
150 changedAt: isChangeShown ? latest.at : null,
151 isChecking: running !== null,
152 words,
153 },
154 }
155 stage = <Raster key="stage" columns={columns} rows={STRIP_ROWS} cells={stageFrame(Date.now(), strip.state, columns)} />
156 } else {
157 strip = null
158 }
159
160 const help = refusal ? (
161 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
162 <Text color="warning" wrap="wrap">
163 Auto mode refused the pane's {refusal.tool === 'vome_health_check' ? 're-check' : 'read of the report'}. Add this to
164 permissions.allow in {settingsFile}, or ask Claude to {refusal.tool === 'vome_health_check' ? 're-check the health score' : 'read the health report'}:
165 </Text>
166 <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
167 <Code source={`"mcp__${refusal.server}__${refusal.tool}"`} />
168 <Box flexDirection="row" gap={2}>
169 <Button key="copy-rule" hotkey="c" onPress={press => $.ui.copy({ text: `"mcp__${refusal.server}__${refusal.tool}"`, surface: press.surface })}>
170 Copy the line
171 </Button>
172 <Button
173 key="retry"
174 hotkey="t"
175 onPress={async () => {
176 await $.state.set(blocked, null)
177 if (refusal.tool === 'vome_health_check') await requestCheck($, refusal.server)
178 else await fetchReport($, refusal.server)
179 }}
180 >
181 Retry
182 </Button>
183 </Box>
184 </Box>
185 ) : null
186
187 if (!current) {
188 return (
189 <Box flexDirection="column">
190 {stage}
191 <Text dimColor wrap="wrap">
192 No health score yet. Ask Claude "how healthy is my Home Assistant?", or press /health. Vome's check scores the home
193 out of 100 and lists what to fix.
194 </Text>
195 {running ? <Text color="suggestion">Checking… the score lands here in a couple of minutes.</Text> : null}
196 {message ? <Text color="warning" wrap="wrap">{message}</Text> : null}
197 {help}
198 {!running ? (
199 <Box marginTop={1}>
200 <Button
201 key="first-check"
202 hotkey="r"
203 onPress={async () => {
204 const server = lastServer ?? (await healthServers($))[0]
205 if (server) await requestCheck($, server)
206 else await $.state.set(note, 'No connected MCP server has vome_health_check: connect Home Assistant through Vome first.')
207 }}
208 >
209 Run the first check
210 </Button>
211 </Box>
212 ) : null}
213 </Box>
214 )
215 }
216
217 const groups = groupFindings(current.findings)
218 const ago = current.generatedAt ? since(Date.parse(current.generatedAt)) : 'unknown'
219 const isStale = current.generatedAt !== null && Date.now() - Date.parse(current.generatedAt) > 7 * 24 * 3600_000
220
221 return (
222 <Box flexDirection="column">
223 {stage}
224 <Text bold wrap="truncate-end">
225 {homeName(current)}
226 <Text color={scoreColour(current.score)}>
227 {' '}
228 {current.score ?? '?'}/100
229 </Text>
230 <Text dimColor color={isStale ? 'warning' : undefined}>
231 {' '}checked {ago}
232 {isStale ? ' (re-check for today\'s)' : ''}
233 </Text>
234 </Text>
235 {current.summary ? (
236 <Text dimColor wrap="wrap">
237 {current.summary}
238 </Text>
239 ) : null}
240 <Text wrap="truncate-end">
241 {current.categories.map((category: Category) => (
242 <Text color={severityColour(category.severity)}>
243 {severityIcon(category.severity)} {category.label}
244 {' '}
245 </Text>
246 ))}
247 </Text>
248 {running ? (
249 <Text color="suggestion" wrap="wrap">
250 Checking… the new score lands here in a couple of minutes ({since(running.startedAt).replace(' ago', '')} so far).
251 </Text>
252 ) : null}
253 {isChangeShown ? (
254 <Box flexDirection="column" marginTop={1}>
255 <Text color={latest.to !== null && latest.from !== null && latest.to < latest.from ? 'warning' : 'success'}>
256 {latest.from ?? '?'} → {latest.to ?? '?'}
257 {latest.resolved.length > 0 ? `: ${latest.resolved.length} finding${latest.resolved.length === 1 ? '' : 's'} gone` : ''}
258 {latest.added.length > 0 ? `, ${latest.added.length} new` : ''}
259 </Text>
260 {latest.resolved.map((finding: Finding) => (
261 <Text color="success" strikethrough wrap="truncate-end">
262 ✓ {finding.title}
263 </Text>
264 ))}
265 </Box>
266 ) : null}
267 {groups.map(group => (
268 <Box flexDirection="column" marginTop={1}>
269 <Text color="subtle">{group.label}</Text>
270 {group.findings.map((finding: Finding) => {
271 const isWorked = worked[finding.id] !== undefined
272 const isNew = isChangeShown && latest.added.includes(finding.id)
273 const isOpen = isDetailed || (finding.severity === 'warn' && !isWorked)
274 return (
275 <Box flexDirection="column">
276 <Box flexDirection="row" justifyContent="space-between">
277 <Text wrap="truncate-end" color={isWorked ? 'suggestion' : undefined}>
278 <Text color={severityColour(finding.severity)}>{isWorked ? '✎' : severityIcon(finding.severity)}</Text> {isNew ? '+ ' : ''}
279 {finding.title}
280 {isWorked ? <Text dimColor> changed; re-check to confirm</Text> : handed[finding.id] ? <Text dimColor> Claude is on it</Text> : null}
281 </Text>
282 {isFixable(finding) && !isWorked ? (
283 <Button key={`fix-${finding.id}`} dimColor={handed[finding.id] !== undefined} onPress={() => fix([finding])}>
284 {handed[finding.id] ? 'Ask again' : 'Fix'}
285 </Button>
286 ) : null}
287 </Box>
288 {isOpen && finding.recommendation ? (
289 <Text dimColor wrap="wrap">
290 {' '}
291 {finding.recommendation}
292 </Text>
293 ) : null}
294 {isDetailed && finding.evidence ? (
295 <Text dimColor wrap="wrap">
296 {' '}
297 {finding.evidence}
298 </Text>
299 ) : null}
300 </Box>
301 )
302 })}
303 </Box>
304 ))}
305 {message ? <Text color="warning" wrap="wrap">{message}</Text> : null}
306 {help}
307 <Box flexDirection="row" gap={2} marginTop={1}>
308 {current.findings.some(f => isFixable(f) && worked[f.id] === undefined) ? (
309 <Button key="fix-all" hotkey="f" onPress={() => fix(current.findings.filter(f => isFixable(f) && worked[f.id] === undefined))}>
310 Fix what you can
311 </Button>
312 ) : null}
313 <Button key="recheck" hotkey="r" onPress={() => requestCheck($, current.server)}>
314 {running ? 'Checking…' : 'Re-check'}
315 </Button>
316 <Button key="details" hotkey="d" dimColor onPress={() => $.state.set(details, !isDetailed)}>
317 {isDetailed ? 'Less' : 'Details'}
318 </Button>
319 <Button
320 key="fx"
321 hotkey="b"
322 dimColor
323 onPress={async () => {
324 isFxOn = !isFxEnabled
325 if (!isFxOn) strip = null
326 await storeSet($, 'fxOn', isFxOn)
327 await $.state.set(fxOn, isFxOn)
328 }}
329 >
330 {isFxEnabled ? 'Animation off' : 'Animation on'}
331 </Button>
332 {current.healthUrl ? <Link href={current.healthUrl}>Full report</Link> : <Link href={README_URL}>About</Link>}
333 </Box>
334 </Box>
335 )
336 })
337}
338
339// ---------------------------------------------------------------- the report
340
341/** A report from vome_health_report's reply; against the one before it, what the new check changed. */
342async function applyReport($: EngineInterface, server: string, raw: string) {
343 lastServer = server
344 const body = parseJson(raw)
345 if (!body) return
346 if (body.found === false) {
347 await $.state.set(note, typeof body.note === 'string' ? body.note : 'No health score on this home yet.')
348 return
349 }
350 const next: Report = {
351 server,
352 home: /\[vome-instance\][^\n]*\bhome="([^"]*)"/.exec(raw)?.[1] ?? 'Home',
353 entityId: String(body.entity_id ?? ''),
354 score: typeof body.score === 'number' ? body.score : null,
355 summary: typeof body.summary === 'string' ? body.summary : '',
356 generatedAt: typeof body.generated_at === 'string' ? body.generated_at : null,
357 categories: Array.isArray(body.categories) ? (body.categories as Category[]) : [],
358 findings: Array.isArray(body.findings) ? (body.findings as Finding[]).filter(f => f && typeof f.title === 'string') : [],
359 healthUrl: typeof body.health_url === 'string' ? body.health_url : null,
360 }
361 const before = (await $.state.get(report)).value ?? null
362 const running = (await $.state.get(checking)).value ?? null
363 const isNewCheck = before !== null && before.server === server && before.generatedAt !== next.generatedAt
364 if (isNewCheck) {
365 const same = (a: Finding, b: Finding) => a.id === b.id || a.title === b.title
366 const delta: Change = {
367 from: before.score,
368 to: next.score,
369 at: Date.now(),
370 resolved: before.findings.filter(old => !next.findings.some(now => same(old, now))),
371 added: next.findings.filter(now => !before.findings.some(old => same(old, now))).map(f => f.id),
372 }
373 await $.state.set(change, delta)
374 await $.state.set(touched, {})
375 }
376 if (running && running.previousGeneratedAt !== next.generatedAt) await $.state.set(checking, null)
377 await $.state.set(report, next)
378 await $.state.set(note, null)
379}
380
381async function fetchReport($: EngineInterface, server: string) {
382 const reply = await callMcp($, server, 'vome_health_report', {})
383 if (reply) await applyReport($, server, reply)
384}
385
386async function startChecking($: EngineInterface, server: string) {
387 const current = (await $.state.get(report)).value ?? null
388 await $.state.set(checking, { server, startedAt: Date.now(), previousGeneratedAt: current?.generatedAt ?? null })
389 await $.ui.open({ id: PANE, title: 'Health' }).catch(() => undefined)
390}
391
392/** r: ask for a fresh check. The person pressed it, so it is theirs; auto mode may still want it allowed. */
393async function requestCheck($: EngineInterface, server: string) {
394 if ((await $.state.get(checking)).value) return
395 const reply = await callMcp($, server, 'vome_health_check', {})
396 if (reply !== null) await startChecking($, server)
397}
398
399/** One background call; a refusal shows the line to allow, any other failure a note. */
400async function callMcp($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<string | null> {
401 try {
402 const result = await $.mcp.call(server, tool, args)
403 const text = result.content.map(block => block.text ?? '').join('\n')
404 if (result.isError) {
405 await $.state.set(note, firstLine(text) ?? `${tool} failed.`)
406 return null
407 }
408 if ((await $.state.get(blocked)).value) await $.state.set(blocked, null)
409 return text
410 } catch (error) {
411 if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
412 await $.state.set(blocked, { server, tool })
413 } else {
414 await $.state.set(note, firstLine(String(error)))
415 }
416 return null
417 }
418}
419
420/** While a check runs, look in on the report until the new one lands. */
421async function poll($: EngineInterface) {
422 if (isPolling) return
423 const running = (await $.state.get(checking)).value ?? null
424 const latest = (await $.state.get(change)).value ?? null
425 if (latest && Date.now() - latest.at < CHANGE_SHOWN_MS + POLL_MS) $.ui.invalidate('ui.render')
426 if (!running) return
427 isPolling = true
428 try {
429 if (Date.now() - running.startedAt > CHECK_GIVE_UP_MS) {
430 await $.state.set(checking, null)
431 await $.state.set(note, 'The check is taking longer than usual. The score lands on the home when it finishes; /health reads it then.')
432 return
433 }
434 await fetchReport($, running.server)
435 $.ui.invalidate('ui.render')
436 } finally {
437 isPolling = false
438 }
439}
440
441/** Repaints the strip; gently at rest, quickly while a check runs or a new score rolls in. */
442async function animate($: EngineInterface) {
443 if (!isFxOn || !strip || isBlitting) return
444 const changed = strip.state.changedAt
445 const isBusy = strip.state.isChecking || (changed !== null && Date.now() - changed < 6_000)
446 frame = (frame + 1) % IDLE_EVERY
447 if (!isBusy && frame !== 0) return
448 isBlitting = true
449 try {
450 const result = await $.ui.blit({ requestId: PANE, key: 'stage', cells: stageFrame(Date.now(), strip.state, strip.columns) })
451 if (result.deny !== undefined) strip = null
452 } finally {
453 isBlitting = false
454 }
455}
456
457// ---------------------------------------------------------------- which change is about which finding
458
459/** Tools that only read: they cannot have fixed anything. */
460function isRead(name: string): boolean {
461 return /^(ha|esphome|nodered|vomehome)_(get|list|read|render|check|camera)|_activity$|_logs$|_validate$|^vome_health/.test(name)
462}
463
464/**
465 * Whether a change Claude made is about this finding: one of its entities named in the call, or
466 * the kind of change its category calls for (the recorder for a flooding sensor, an automation for
467 * the automation findings, a removal for the leftover entities).
468 */
469export function touches(finding: Finding, tool: string, text: string): boolean {
470 if (finding.entities?.some(id => text.includes(id))) return true
471 switch (finding.category) {
472 case 'flapping':
473 case 'recorder':
474 return (tool === 'ha_write_config_file' && /recorder/.test(text)) || /update_interval/.test(text)
475 case 'automations':
476 return /automation/.test(tool) || /"automation\.|"domain":"automation"/.test(text)
477 case 'dead_entities':
478 return /delete|remove/.test(tool) && /entit|config_entry/.test(tool + text)
479 case 'batteries':
480 return /batter/.test(text)
481 default:
482 return false
483 }
484}
485
486/** A finding there is something to do about: to fix or worth doing, and one the check could run. */
487function isFixable(finding: Finding): boolean {
488 return (finding.severity === 'warn' || finding.severity === 'advice') && !/could not be checked/i.test(finding.title)
489}
490
491/** What Fix sends Claude: the finding as the check wrote it, and how to go about it. */
492export function fixPrompt(findings: Finding[], home: string): string {
493 const one = (f: Finding) =>
494 [
495 `- **${f.title}**`,
496 f.evidence ? ` Evidence: ${f.evidence}` : null,
497 f.recommendation ? ` Recommendation: ${f.recommendation}` : null,
498 f.entities?.length ? ` Entities: ${f.entities.join(', ')}` : null,
499 ]
500 .filter(Boolean)
501 .join('\n')
502 return [
503 findings.length === 1
504 ? `Please fix this finding from Vome's health check of ${home} (sent from the health pane's Fix button):`
505 : `Please fix what you can of these ${findings.length} findings from Vome's health check of ${home} (sent from the health pane):`,
506 '',
507 ...findings.map(one),
508 '',
509 "Use the Home Assistant tools to make the changes. Look before you change anything, explain what you'll do, and ask me before anything that cannot be undone (deleting entities, integrations or automations). When you're done, run vome_health_check so the score catches up.",
510 ].join('\n')
511}
512
513// ---------------------------------------------------------------- small things
514
515function groupFindings(findings: Finding[]): { label: string; findings: Finding[] }[] {
516 const couldNot = (f: Finding) => /could not be checked/i.test(f.title)
517 const labels: Record<string, string> = { warn: 'To fix', advice: 'Worth doing', info: 'Fine' }
518 const groups = SEVERITY_ORDER.map(severity => ({
519 label: labels[severity] ?? severity,
520 findings: findings.filter(f => !couldNot(f) && f.severity === severity),
521 }))
522 const other = findings.filter(f => !couldNot(f) && !SEVERITY_ORDER.includes(f.severity))
523 const skipped = findings.filter(couldNot)
524 return [...groups, { label: 'Other', findings: other }, { label: 'Not checked this time', findings: skipped }].filter(g => g.findings.length > 0)
525}
526
527function severityIcon(severity: string): string {
528 return severity === 'warn' ? '⚠' : severity === 'advice' ? '◆' : severity === 'info' ? '✓' : '·'
529}
530
531function severityColour(severity: string): string | undefined {
532 return severity === 'warn' ? 'warning' : severity === 'advice' ? 'suggestion' : severity === 'info' ? 'success' : undefined
533}
534
535function scoreColour(score: number | null): string | undefined {
536 if (score === null) return undefined
537 return score >= 85 ? 'success' : score >= 65 ? 'warning' : 'error'
538}
539
540function homeName(current: Report): string {
541 return current.home || 'Home'
542}
543
544function since(at: number): string {
545 const s = Math.max(0, Math.round((Date.now() - at) / 1000))
546 if (s < 90) return `${s} s ago`
547 if (s < 5400) return `${Math.round(s / 60)} min ago`
548 if (s < 2 * 86400) return `${Math.round(s / 3600)} h ago`
549 return `${Math.round(s / 86400)} days ago`
550}
551
552async function healthServers($: EngineInterface): Promise<string[]> {
553 return (await $.tool.list())
554 .map(tool => /^mcp__(.+)__vome_health_report$/.exec(tool.name)?.[1])
555 .filter((server): server is string => !!server)
556}
557
558function firstLine(text: string | null): string | null {
559 return text ? (text.split('\n').find(line => line.trim()) ?? null) : null
560}
561
562/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
563function parseJson(raw: string): Record<string, unknown> | null {
564 const text = raw.replace(/\[vome-instance\][^\n]*/g, '')
565 const start = text.search(/[[{]/)
566 const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'))
567 if (start < 0 || end <= start) return null
568 try {
569 const value = JSON.parse(text.slice(start, end + 1)) as unknown
570 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
571 } catch {
572 return null
573 }
574}
575
576async function storeGet($: EngineInterface, key: string): Promise<unknown> {
577 try {
578 return await $.store.get(key)
579 } catch {
580 return undefined
581 }
582}
583
584async function storeSet($: EngineInterface, key: string, value: unknown) {
585 try {
586 await $.store.set(key, value as never)
587 } catch {
588 // A store that cannot keep it: the toggle still works for this session.
589 }
590}
591hooks/stage.ts 212 lines1// The dot-matrix strip over the health pane: the home's score in big round
2// dots, a heart beating beside it and a pulse running underneath, coloured by
3// how the home is doing. While a check runs a sweep crosses the strip; when a
4// new score lands it rolls up (or down) to it, with fireworks for a better one.
5// Pure: one frame per call, as braille Raster cells (2 x 4 dots a cell, one
6// colour a cell), the last row a caption.
7
8export const STRIP_ROWS = 6
9
10export type StageState = {
11 /** The score shown, out of 100; null before there is one. */
12 score: number | null
13 /** The score before the latest check, rolled from when it changed. */
14 from: number | null
15 changedAt: number | null
16 isChecking: boolean
17 words: string
18}
19
20const PANEL = 0x0b1020
21const DIM = 0x3a4252
22const PAGE = 0xd8dee9
23const PACKET = 0x64d2ff
24const GREEN = 0x30d158
25const AMBER = 0xffb43c
26const RED = 0xff453a
27const QUIET = 0x8e96a3
28
29const ROLL_MS = 2_400
30const PARTY_MS = 3_200
31
32/** 5 x 7 digits, a row a string. */
33const FONT: Record<string, string[]> = {
34 '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
35 '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
36 '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
37 '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
38 '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
39 '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
40 '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
41 '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
42 '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
43 '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
44 '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
45 '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
46}
47const HEART = ['0110110', '1111111', '1111111', '0111110', '0011100', '0001000']
48
49type Canvas = { w: number; h: number; px: Uint32Array }
50
51export function colourFor(score: number | null): number {
52 if (score === null) return QUIET
53 return score >= 85 ? GREEN : score >= 65 ? AMBER : RED
54}
55
56export function stageFrame(now: number, state: StageState, columns: number): string {
57 const c: Canvas = { w: columns * 2, h: (STRIP_ROWS - 1) * 4, px: new Uint32Array(columns * 2 * (STRIP_ROWS - 1) * 4) }
58 const t = now / 1000
59 const since = state.changedAt === null ? Infinity : now - state.changedAt
60 const rolling = state.from !== null && state.score !== null && since < ROLL_MS
61 const shown = rolling ? Math.round(state.from! + (state.score! - state.from!) * ease(since / ROLL_MS)) : state.score
62 const colour = colourFor(shown)
63
64 // The score, big, at two dots a font pixel; "/100" small beside it.
65 const text = state.isChecking && state.score === null ? '??' : shown === null ? '?' : String(shown)
66 const scale = 2
67 const glyphW = 5 * scale + 2
68 const left = Math.max(2, Math.floor(c.w / 2) - Math.floor((text.length * glyphW + 22) / 2))
69 const top = 1
70 for (const [i, ch] of [...text].entries()) {
71 const shimmer = state.isChecking ? 0.45 + 0.35 * Math.sin(t * 4 + i) : 1
72 glyph(c, FONT[ch] ?? FONT['?']!, left + i * glyphW, top, scale, scale_(colour, shimmer))
73 }
74 const after = left + text.length * glyphW + 1
75 for (const [i, ch] of [...'/100'].entries()) glyph(c, FONT[ch] ?? FONT['?']!, after + i * 6, top + 7, 1, QUIET)
76
77 // A heart beating to the left of it: two quick beats, then rest.
78 const beat = t % 1.1
79 const big = beat < 0.12 || (beat > 0.24 && beat < 0.34)
80 const hx = left - 12
81 if (hx > 2) glyph(c, HEART, hx, top + 4 - (big ? 1 : 0), 1, big ? colour : scale_(colour, 0.55))
82
83 // The pulse under it, running right to left.
84 const y0 = c.h - 3
85 const shift = Math.floor(t * 28)
86 for (let x = 0; x < c.w; x++) {
87 const phase = (x + shift) % 48
88 const y = phase === 20 ? y0 - 3 : phase === 21 ? y0 + 2 : phase === 22 ? y0 - 1 : y0
89 const fade = 0.35 + 0.65 * (x / c.w)
90 set(c, c.w - 1 - x, y, scale_(colour, fade))
91 }
92
93 // A check in progress: a sweep crossing the strip, leaving a fading trail.
94 if (state.isChecking) {
95 const at = ((t * 0.45) % 1) * (c.w + 20) - 10
96 for (let k = 0; k < 10; k++) {
97 const x = Math.round(at - k * 2)
98 for (let y = 0; y < c.h - 4; y += 2) set(c, x, y + (k % 2), scale_(PACKET, 1 - k / 10))
99 }
100 }
101
102 // A better score: fireworks once it has rolled up to it.
103 if (state.from !== null && state.score !== null && state.score > state.from && since >= ROLL_MS && since < ROLL_MS + PARTY_MS) {
104 fireworks(c, (since - ROLL_MS) / 1000, left + (text.length * glyphW) / 2, glyphW * 3)
105 }
106
107 return encode(c, columns, state.words, colour)
108}
109
110function ease(p: number): number {
111 const q = Math.max(0, Math.min(1, p))
112 return 1 - (1 - q) ** 3
113}
114
115function glyph(c: Canvas, rows: string[], x: number, y: number, scale: number, colour: number) {
116 for (const [r, row] of rows.entries()) {
117 for (const [k, bit] of [...row].entries()) {
118 if (bit !== '1') continue
119 for (let dx = 0; dx < scale; dx++) for (let dy = 0; dy < scale; dy++) set(c, x + k * scale + dx, y + r * scale + dy, colour)
120 }
121 }
122}
123
124function fireworks(c: Canvas, since: number, centre: number, spread: number) {
125 for (let b = 0; b < 3; b++) {
126 const age = since - b * 0.45
127 if (age < 0 || age > 1.8) continue
128 const bx = centre + (b - 1) * spread
129 const by = 5 + (b % 2) * 4
130 const r = age * 8
131 for (let k = 0; k < 14; k++) {
132 const angle = (k / 14) * Math.PI * 2 + b
133 const bright = hue((b * 0.3 + k / 14) % 1)
134 set(c, Math.round(bx + Math.cos(angle) * r * 1.6), Math.round(by + Math.sin(angle) * r + age * age * 2), age > 1.3 ? scale_(bright, 0.5) : bright)
135 }
136 }
137}
138
139function hue(h: number): number {
140 const f = (n: number) => {
141 const k = (n + h * 6) % 6
142 return Math.round(255 * (1 - Math.max(0, Math.min(1, Math.min(k, 4 - k)))))
143 }
144 return (f(5) << 16) | (f(3) << 8) | f(1) || PAGE
145}
146
147function scale_(colour: number, by: number): number {
148 const f = Math.max(0.15, Math.min(1, by))
149 const r = Math.round(((colour >> 16) & 255) * f)
150 const g = Math.round(((colour >> 8) & 255) * f)
151 const b = Math.round((colour & 255) * f)
152 return (r << 16) | (g << 8) | b || DIM
153}
154
155function set(c: Canvas, x: number, y: number, colour: number) {
156 if (x < 0 || y < 0 || x >= c.w || y >= c.h) return
157 c.px[y * c.w + x] = colour
158}
159
160const DOT_BITS = [
161 [0x01, 0x02, 0x04, 0x40],
162 [0x08, 0x10, 0x20, 0x80],
163]
164
165function encode(c: Canvas, columns: number, words: string, colour: number): string {
166 const bytes = new Uint8Array(columns * STRIP_ROWS * 12)
167 const view = new DataView(bytes.buffer)
168 const put = (cell: number, code: number, fg: number) => {
169 view.setUint32(cell * 12, code, true)
170 view.setUint32(cell * 12 + 4, fg, true)
171 view.setUint32(cell * 12 + 8, PANEL, true)
172 }
173 for (let row = 0; row < STRIP_ROWS - 1; row++) {
174 for (let col = 0; col < columns; col++) {
175 let bits = 0
176 let fg = PANEL
177 for (let dx = 0; dx < 2; dx++) {
178 for (let dy = 0; dy < 4; dy++) {
179 const dot = c.px[(row * 4 + dy) * c.w + col * 2 + dx] ?? 0
180 if (!dot) continue
181 bits |= DOT_BITS[dx]![dy]!
182 if (fg === PANEL || dot !== QUIET) fg = dot
183 }
184 }
185 put(row * columns + col, bits ? 0x2800 + bits : 0x20, fg)
186 }
187 }
188 const base = (STRIP_ROWS - 1) * columns
189 const text = words.length > columns ? `${words.slice(0, columns - 1)}…` : words
190 const start = Math.max(0, Math.floor((columns - text.length) / 2))
191 for (let col = 0; col < columns; col++) {
192 const i = col - start
193 const code = i >= 0 && i < text.length ? text.charCodeAt(i) : 0x20
194 const isPrintable = code >= 0x20 && code !== 0x7f && code < 0x1100
195 put(base + col, isPrintable || code === 0x2026 || code === 0x2713 || code === 0x2192 || code === 0x2193 || code === 0x2191 ? code : 0xb7, colour)
196 }
197 return base64(bytes)
198}
199
200const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
201
202function base64(bytes: Uint8Array): string {
203 let out = ''
204 for (let i = 0; i < bytes.length; i += 3) {
205 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
206 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
207 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
208 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
209 }
210 return out
211}
212types/index.d.ts 63 lines1/** One thing Vome's health check found, as the MCP's vome_health_report returns it. */
2export type Finding = {
3 id: string
4 category: string
5 severity: string
6 title: string
7 evidence?: string
8 recommendation?: string
9 /** The entities it is about, when it is about some. */
10 entities?: string[]
11}
12
13export type Category = { id: string; label: string; severity: string }
14
15/** The home's latest health report, and where it was read from. */
16export type Report = {
17 server: string
18 /** The home's name, from the reply's `[vome-instance]` stamp. */
19 home: string
20 entityId: string
21 score: number | null
22 summary: string
23 /** ISO time the check ran. */
24 generatedAt: string | null
25 categories: Category[]
26 findings: Finding[]
27 healthUrl: string | null
28}
29
30/** What the latest check changed against the one before it. */
31export type Change = {
32 from: number | null
33 to: number | null
34 at: number
35 /** Findings the new check no longer has. */
36 resolved: Finding[]
37 /** Finding ids that are new in this check. */
38 added: string[]
39}
40
41/** A check that is running: the report it should replace. */
42export type Checking = { server: string; startedAt: number; previousGeneratedAt: string | null }
43
44declare module 'claude-code' {
45 interface PluginState {
46 'vome-health': {
47 report: Report | null
48 change: Change | null
49 checking: Checking | null
50 /** Finding ids Claude has changed something for since the report, and when. */
51 touched: Record<string, number>
52 /** Findings handed to Claude with Fix, and when. */
53 asked: Record<string, number>
54 /** Each finding's evidence and recommendation, not just its title. */
55 details: boolean
56 fxOn: boolean
57 /** Auto mode refused one of the pane's background calls: on which server, and which tool. */
58 blocked: { server: string; tool: string } | null
59 note: string | null
60 }
61 }
62}
63