SLOPSHOPPER

vome-health

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…

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vome-health
│ ┃ Health ✕ › fix the failing auth test and add an audit log call │ ┃ ⢀⡰⠶⠶⢆⡀ │ ┃ ⢠⣶⣴⣦ ⠈⠁ ⢀⡸⠇ ⏺ Read(src/auth.ts) │ ┃ ⠙⠟⠁ ⠰⠎⠁ ⡰⠁⠐⡇ ⡎⡩⡆⡎⡩⡆ ⎿ Read 6 lines │ ┃ ⠄ ⠰⠆ ⠜ ⠠⠧ ⠫⠤⠃⠫⠤⠃ ⠄ ⏺ Update(src/auth.ts) │ ┃ ⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⢁⠐⠒⠒⠒⠒⠒⠒⠒ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No health score yet. Ask Claude "how healthy ⎿ 3 pass, 1 fail │ ┃ is my Home Assistant?", or press /health. │ ┃ Vome's check scores the home out of 100 and ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ lists what to fix. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Run the first check ] │ › /health │ ⎿ vome-health: No connected MCP server has vome_health_report yet: │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Health
⢀⡰⠶⠶⢆⡀ ⢠⣶⣴⣦ ⠈⠁ ⢀⡸⠇ ⠙⠟⠁ ⠰⠎⠁ ⡰⠁⠐⡇ ⡎⡩⡆⡎⡩⡆ ⠄ ⠰⠆ ⠜ ⠠⠧ ⠫⠤⠃⠫⠤⠃ ⠄ ⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⢁⠐⠒⠒⠒⠒⠒⠒⠒ No health score yet. Ask Claude "how healthy is my Home Assistant?", or press /health. Vome's check scores the home out of 100 and lists what to fix. [ Run the first check ]
README

vome-health: your Home Assistant's health, beside Claude Code

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 health pane: 72 out of 100, the findings to fix with a Fix button each, and what is fine.

  • The score, big, in the terminal, with a heart beating beside it, coloured by how the home is doing, and when it was checked.
  • What the check found, by severity: To fix, Worth doing, Fine, each with what to do about it. Devices flooding the recorder, entities left behind by removed integrations, automations that are off or never run, batteries not reporting, error noise.
  • What Claude is working on: when Claude changes something a finding is about (one of its entities, the recorder for a flooding sensor, an automation for the automation findings), the finding is marked ✎ until a re-check settles it.
  • The re-check: press r (or ask Claude). The new score rolls in, findings it no longer has are struck through ✓, and a better score gets fireworks.

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.

Quickstart

1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.

Add the Vome app to your Home Assistant.

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.

Allow its reads (auto mode)

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"] } }

Keys

/healthopen the pane and read the latest report
rrun a fresh check
deach finding's evidence, not just its title
bthe animation off or on (remembered)

Privacy

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.

Source 3 files
hooks/register.tsx 591 lines
1// 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}
591
hooks/stage.ts 212 lines
1// 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}
212
types/index.d.ts 63 lines
1/** 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