mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-10-02 14:32:42 +02:00
Compare commits
5 Commits
c0aa5af0ce
...
49dd9f0e4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49dd9f0e4e | ||
|
|
b453c60c33 | ||
|
|
aceeaf36b4 | ||
|
|
9faae5cc90 | ||
|
|
d3d3476986 |
@@ -1,3 +1,37 @@
|
||||
## 2026.08.03 (03-08-2026)
|
||||
- Performance: Claude Desktop now uses the GPU instead of rendering on the CPU.
|
||||
Under Xvfb, Chromium probed GLX, found only Xvfb's software path, and fell back to
|
||||
`--use-gl=disabled` + `--disable-gpu-compositing`, making the renderer the add-on's largest
|
||||
CPU consumer. It is now launched with `--ozone-platform=x11 --use-gl=angle --use-angle=gl-egl`,
|
||||
but only when the new `claude-gpu-probe` confirms Claude Desktop's own bundled ANGLE can
|
||||
create a hardware GL context on this host. New `gpu_acceleration` option (`auto`/`on`/`off`,
|
||||
default `auto`); any probe failure keeps the previous software rendering unchanged.
|
||||
- Performance: new `max_resolution` option (default `1920x1080`) caps the virtual screen via the
|
||||
base image's `MAX_RES`. Xvfb previously ran at 15360x8640, so Xvfb and the Selkies capture
|
||||
loop tracked damage over a 133-megapixel area continuously, even with no browser connected.
|
||||
Selkies still resizes dynamically below the cap.
|
||||
- Performance: Claude Code now talks to the Home Assistant MCP server over its native HTTP
|
||||
transport instead of through the `mcp-proxy` stdio bridge, removing one Python process per
|
||||
Claude Code session (~45 MB of private resident memory each). Claude Desktop keeps the
|
||||
bridge, as the config schema for a remote entry there is not confirmed.
|
||||
- Performance: new `mcp_servers_desktop` / `mcp_servers_code` options select which MCP servers
|
||||
each client registers. Every stdio MCP server is a separate process per client, and Desktop
|
||||
starts another full set per Claude Code session it hosts. Defaults register all of them in
|
||||
both clients, i.e. the previous behaviour.
|
||||
- Fix: the Dockerfile's Intel graphics block was dead code. It was gated on `TARGETARCH`, which
|
||||
the repo's builder does not pass, so it never ran: the shipped amd64 image has no `vainfo` and
|
||||
no `intel-media-va-driver-non-free`. It now uses `BUILD_ARCH`, and its Vulkan ICD check no
|
||||
longer names `intel_icd.x86_64.json`, a file Debian does not ship.
|
||||
- Fix: stale Home Assistant MCP registrations (and the bearer token in them) are now removed
|
||||
from Claude Code's config when `enable_ha_mcp` is turned off. Ownership of an HTTP entry is
|
||||
recorded when the add-on writes it, so a manually configured `homeassistant` server is never
|
||||
claimed, overwritten, or deleted — not even when it sits on the default URL.
|
||||
- `--disable-dev-shm-usage` is now applied only when `/dev/shm` is actually small (under
|
||||
256 MB). It is a workaround for Docker's 64 MB default, but Home Assistant ignores the add-on's
|
||||
`shm_size` so the real size varies per install; the size is now read at startup, keeping the
|
||||
crash workaround where it is needed and dropping it where it only pushed Chromium's shared
|
||||
memory into ordinary files. If the size cannot be determined, the flag is kept.
|
||||
|
||||
## 2026.08.02 (02-08-2026)
|
||||
- Minor bugs fixed
|
||||
|
||||
|
||||
@@ -146,7 +146,19 @@ RUN install -d -m 0755 /etc/apt/keyrings && \
|
||||
# /dev/dri nodes. Explicitly install the amd64 userspace stack needed for accelerated
|
||||
# OpenGL rendering, VA-API video encoding, and Vulkan, then fail the build if any driver
|
||||
# payload is missing. Keep aarch64 unchanged because these Intel packages are amd64-only.
|
||||
RUN if [[ "${TARGETARCH}" == "amd64" ]]; then \
|
||||
#
|
||||
# Gate on BUILD_ARCH, not TARGETARCH. TARGETARCH is a BuildKit-provided platform ARG; the
|
||||
# repo's builder (.github/workflows/onpush_builder.yaml) passes BUILD_ARCH explicitly and
|
||||
# that is the contract this repo can rely on. This block previously used TARGETARCH and
|
||||
# silently never ran: the shipped amd64 image has no `vainfo` and no
|
||||
# `intel-media-va-driver-non-free`, and its apt history contains no matching install — so
|
||||
# the "fail the build if a payload is missing" guarantee below had never once executed.
|
||||
#
|
||||
# The Vulkan ICD payload is checked via the driver directory rather than a single
|
||||
# distro-specific filename: the previous `test -f intel_icd.x86_64.json` named a file Debian
|
||||
# does not ship (it installs `intel_icd.json`), so restoring the guard without this change
|
||||
# would have turned dead code straight into a failing build.
|
||||
RUN if [[ "${BUILD_ARCH}" == "amd64" ]]; then \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
intel-media-va-driver-non-free \
|
||||
@@ -155,7 +167,7 @@ RUN if [[ "${TARGETARCH}" == "amd64" ]]; then \
|
||||
vainfo && \
|
||||
test -f /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so && \
|
||||
test -f /usr/lib/x86_64-linux-gnu/dri/iris_dri.so && \
|
||||
test -f /usr/share/vulkan/icd.d/intel_icd.x86_64.json && \
|
||||
ls /usr/share/vulkan/icd.d/intel_icd*.json > /dev/null && \
|
||||
command -v vainfo > /dev/null; \
|
||||
fi && \
|
||||
apt-get clean && \
|
||||
|
||||
@@ -97,6 +97,8 @@ Git synchronization hooks. A repository is indexed only when it is listed in
|
||||
| `KEYBOARD` | | Optional Selkies keyboard layout. |
|
||||
| `PASSWORD` | | Optional password for direct Selkies ports. |
|
||||
| `DRINODE` | | Optional GPU device override for Selkies. |
|
||||
| `gpu_acceleration` | `auto` | Whether Claude Desktop renders on the GPU. `auto` adds Chromium's ANGLE/EGL flags only when a probe confirms a hardware GL context is available, `on` forces them without probing, `off` keeps software rendering. Use `on` with care: it skips every safety check, so on a host that cannot actually drive those flags the desktop can come up black — set the option back to `auto` or `off` to recover. |
|
||||
| `max_resolution` | `1920x1080` | Caps the virtual screen. Selkies still resizes dynamically below this; raise it only if you drive the desktop from a larger display. |
|
||||
| `DNS_server` | `8.8.8.8` | DNS server used by the standard DNS module. |
|
||||
| `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. |
|
||||
| `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. |
|
||||
@@ -106,6 +108,8 @@ Git synchronization hooks. A repository is indexed only when it is listed in
|
||||
| `install_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. |
|
||||
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
|
||||
| `tokensave_project_paths` | `[]` | Explicit absolute Git repository paths to initialize or sync at startup. |
|
||||
| `mcp_servers_desktop` | all | Which managed MCP servers Claude Desktop registers (`headroom`, `tokensave`, `homeassistant`, `codex`). |
|
||||
| `mcp_servers_code` | all | Which managed MCP servers Claude Code registers. Each stdio server is a separate process per client, and Desktop starts another set per Claude Code session it hosts, so trimming this is the cheapest way to cut memory. |
|
||||
| `install_caveman` | `false` | Install the third-party Caveman Claude Code plugin at startup. |
|
||||
| `install_codex_cli` | `false` | Install the latest stable OpenAI Codex CLI at startup and register its native MCP server so Claude can delegate work to ChatGPT Codex. |
|
||||
| `codex_sandbox_mode` | `workspace-write` | Filesystem scope Codex runs with: `read-only`, `workspace-write`, or `danger-full-access`. |
|
||||
|
||||
@@ -56,6 +56,7 @@ options:
|
||||
github_username: ""
|
||||
enable_tools_health_report: true
|
||||
expose_headroom_dashboard: false
|
||||
gpu_acceleration: auto
|
||||
headroom_auto_compress: true
|
||||
headroom_wrap_claude_code: true
|
||||
install_caveman: false
|
||||
@@ -65,6 +66,17 @@ options:
|
||||
install_headroom: true
|
||||
install_rtk: true
|
||||
install_tokensave: true
|
||||
max_resolution: 1920x1080
|
||||
mcp_servers_desktop:
|
||||
- headroom
|
||||
- tokensave
|
||||
- homeassistant
|
||||
- codex
|
||||
mcp_servers_code:
|
||||
- headroom
|
||||
- tokensave
|
||||
- homeassistant
|
||||
- codex
|
||||
permission_mode: auto
|
||||
tokensave_project_paths: []
|
||||
panel_admin: false
|
||||
@@ -106,6 +118,7 @@ schema:
|
||||
github_username: str?
|
||||
enable_tools_health_report: bool
|
||||
expose_headroom_dashboard: bool
|
||||
gpu_acceleration: list(auto|on|off)?
|
||||
headroom_auto_compress: bool?
|
||||
headroom_wrap_claude_code: bool
|
||||
install_caveman: bool
|
||||
@@ -115,11 +128,16 @@ schema:
|
||||
install_headroom: bool
|
||||
install_rtk: bool
|
||||
install_tokensave: bool
|
||||
max_resolution: str?
|
||||
mcp_servers_desktop:
|
||||
- list(headroom|tokensave|homeassistant|codex)
|
||||
mcp_servers_code:
|
||||
- list(headroom|tokensave|homeassistant|codex)
|
||||
permission_mode: list(strict|auto|bypass)
|
||||
tokensave_project_paths:
|
||||
- str
|
||||
slug: claude_desktop
|
||||
udev: true
|
||||
url: https://github.com/alexbelgium/hassio-addons
|
||||
version: "2026.08.02"
|
||||
version: "2026.08.03"
|
||||
video: true
|
||||
|
||||
@@ -20,4 +20,67 @@
|
||||
# Headroom is intentionally not injected into the Desktop process: Claude Desktop
|
||||
# force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom
|
||||
# MCP tools instead.
|
||||
exec claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=basic
|
||||
|
||||
# GPU acceleration.
|
||||
#
|
||||
# Under Xvfb, Chromium probes GLX, finds only Xvfb's indirect/software path, and falls back to
|
||||
# rendering everything on the CPU (`--use-gl=disabled` on the GPU process, and
|
||||
# `--disable-gpu-compositing` on the renderer). On a small Home Assistant host that is the
|
||||
# add-on's single largest CPU consumer.
|
||||
#
|
||||
# Routing Chromium through ANGLE's OpenGL backend over EGL uses the real render node instead.
|
||||
# The flags are only added when /usr/local/bin/claude-gpu-probe confirms that Claude Desktop's
|
||||
# own bundled ANGLE can create a hardware GL context here, because forcing them on a host with
|
||||
# no render node (or one where Mesa falls back to llvmpipe) trades a working software desktop
|
||||
# for a black window or a GPU-process crash loop. Any probe failure leaves the command line
|
||||
# untouched, i.e. exactly the pre-existing software-rendering behaviour.
|
||||
#
|
||||
# `--use-angle=gles-egl` is deliberately not used: Mesa rejects it with "Intel or NVIDIA
|
||||
# OpenGL ES drivers are not supported".
|
||||
#
|
||||
# /run/claude-desktop-gpu-mode is written each boot by /etc/cont-init.d/85-openbox_autostart.sh
|
||||
# from the `gpu_acceleration` add-on option (auto|on|off).
|
||||
GPU_FLAGS=""
|
||||
GPU_MODE="auto"
|
||||
if [ -r /run/claude-desktop-gpu-mode ]; then
|
||||
GPU_MODE="$(cat /run/claude-desktop-gpu-mode)"
|
||||
fi
|
||||
case "$GPU_MODE" in
|
||||
off)
|
||||
echo "claude-desktop: gpu_acceleration=off; using software rendering" >&2
|
||||
;;
|
||||
on)
|
||||
# Escape hatch for hosts where the probe is wrong in either direction.
|
||||
GPU_FLAGS="--ozone-platform=x11 --use-gl=angle --use-angle=gl-egl"
|
||||
echo "claude-desktop: gpu_acceleration=on; forcing ANGLE/EGL without probing" >&2
|
||||
;;
|
||||
*)
|
||||
# Bounded: this sits in the desktop's startup path, and a driver that wedges during
|
||||
# EGL init must not leave the user staring at an empty screen. A timeout is treated
|
||||
# exactly like a failed probe, i.e. software rendering.
|
||||
if timeout 15 claude-gpu-probe; then
|
||||
GPU_FLAGS="--ozone-platform=x11 --use-gl=angle --use-angle=gl-egl"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Shared memory.
|
||||
#
|
||||
# --disable-dev-shm-usage exists because Docker's default /dev/shm is 64 MB, which is not
|
||||
# enough for Chromium's renderers and produced a crash loop here (see the 1.3 changelog entry).
|
||||
# Home Assistant ignores the add-on's shm_size, so the size cannot be set from this repo and
|
||||
# genuinely varies between installs — it is 7.7 GB on some hosts and the 64 MB default on
|
||||
# others. Hardcoding either answer is wrong, so ask the kernel: keep the workaround when
|
||||
# /dev/shm is small, and drop it when there is plenty, where it would otherwise push Chromium's
|
||||
# shared memory into ordinary files for no benefit. If the size cannot be determined, keep the
|
||||
# flag — the crash it prevents is worse than the overhead it costs.
|
||||
SHM_FLAGS="--disable-dev-shm-usage"
|
||||
SHM_KB="$(df -k /dev/shm 2> /dev/null | awk 'NR==2 {print $2}')"
|
||||
if [ -n "$SHM_KB" ] && [ "$SHM_KB" -ge 262144 ]; then
|
||||
SHM_FLAGS=""
|
||||
fi
|
||||
|
||||
# GPU_FLAGS and SHM_FLAGS must stay unquoted so they expand to separate arguments, or to
|
||||
# nothing at all.
|
||||
# shellcheck disable=SC2086
|
||||
exec claude-desktop --no-sandbox --password-store=basic $SHM_FLAGS $GPU_FLAGS
|
||||
|
||||
66
claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh
Executable file
66
claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
# shellcheck shell=bash
|
||||
set -e
|
||||
|
||||
# Cap the virtual screen the desktop is drawn on.
|
||||
#
|
||||
# The Selkies base image starts Xvfb at DEFAULT_RES=15360x8640 so that a client on any monitor
|
||||
# can resize into it. Nothing here needs a 133-megapixel screen: it enlarges the area Xvfb and
|
||||
# the Selkies capture loop track for damage on every frame, which the add-on pays for
|
||||
# continuously — measurably so even with no browser connected at all.
|
||||
#
|
||||
# MAX_RES is the base image's own knob for this (svc-xorg prefers it over DEFAULT_RES) and it
|
||||
# only sets the *maximum*; Selkies still resizes dynamically underneath it, so a smaller cap
|
||||
# costs nothing until a client actually asks for something larger.
|
||||
#
|
||||
# This is a CPU and address-space saving, not a memory one: the framebuffer is a lazily
|
||||
# populated SysV shared segment, so the unused portion of the oversized screen was never
|
||||
# resident to begin with.
|
||||
#
|
||||
# Note SELKIES_MANUAL_WIDTH/HEIGHT is a different knob that *pins* the resolution and disables
|
||||
# dynamic resizing. It is deliberately not used here.
|
||||
MAX_RESOLUTION="$(bashio::config 'max_resolution' '1920x1080')"
|
||||
|
||||
if [ -z "$MAX_RESOLUTION" ]; then
|
||||
bashio::log.info "max_resolution is empty; leaving the base image's default virtual screen size"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Matched with bash's own =~ rather than grep: grep anchors per *line*, so a multi-line value
|
||||
# such as "1920x1080\n640x480" satisfies ^...$ on its first line and would be passed through to
|
||||
# Xvfb verbatim. Bash anchors the whole string, so an embedded newline is rejected.
|
||||
if [[ ! "$MAX_RESOLUTION" =~ ^[0-9]{1,5}x[0-9]{1,5}$ ]]; then
|
||||
bashio::log.warning "max_resolution '${MAX_RESOLUTION}' is not WIDTHxHEIGHT; leaving the base image default"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A syntactically valid but nonsensical size (0x0, 99999x99999) would either stop Xvfb from
|
||||
# starting at all or ask it for a framebuffer larger than the default this option exists to
|
||||
# shrink. Bound it to something Xvfb and Selkies can actually serve; the upper bound is the
|
||||
# base image's own default, so this option can only ever reduce the screen.
|
||||
MAX_WIDTH="${MAX_RESOLUTION%%x*}"
|
||||
MAX_HEIGHT="${MAX_RESOLUTION##*x}"
|
||||
if [ "$MAX_WIDTH" -lt 640 ] || [ "$MAX_HEIGHT" -lt 480 ] ||
|
||||
[ "$MAX_WIDTH" -gt 15360 ] || [ "$MAX_HEIGHT" -gt 8640 ]; then
|
||||
bashio::log.warning "max_resolution '${MAX_RESOLUTION}' is outside the supported range (640x480 to 15360x8640); leaving the base image default"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# cont-init.d completes before any s6-rc service starts, so svc-xorg picks this up on the same
|
||||
# boot. Both paths are written because the base image's scripts read the legacy /var/run alias.
|
||||
written=0
|
||||
for envdir in /var/run/s6/container_environment /run/s6/container_environment; do
|
||||
if [ -d "$envdir" ]; then
|
||||
printf '%s' "$MAX_RESOLUTION" > "${envdir}/MAX_RES"
|
||||
written=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Claiming success after writing nothing would send someone hunting for a cap that svc-xorg
|
||||
# never saw.
|
||||
if [ "$written" -eq 0 ]; then
|
||||
bashio::log.warning "No s6 environment directory found; leaving the base image's default virtual screen size"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bashio::log.info "Virtual screen capped at ${MAX_RESOLUTION} (Selkies still resizes dynamically below this)"
|
||||
@@ -308,10 +308,13 @@ if bashio::config.true 'install_codex_cli'; then
|
||||
fi
|
||||
|
||||
HA_MCP_ENABLED=false
|
||||
HA_MCP_URL=""
|
||||
HA_MCP_TOKEN=""
|
||||
# Read unconditionally, even when enable_ha_mcp is off. Home Assistant keeps an option's value
|
||||
# when its toggle is disabled, and the reconciliation below needs this URL to recognise the
|
||||
# HTTP entry it previously wrote so that it can be removed — together with the bearer token
|
||||
# inside it — rather than orphaned in ~/.claude.json.
|
||||
HA_MCP_URL="$(bashio::config 'ha_mcp_url' 'http://homeassistant:8123/api/mcp')"
|
||||
if bashio::config.true 'enable_ha_mcp'; then
|
||||
HA_MCP_URL="$(bashio::config 'ha_mcp_url' 'http://homeassistant:8123/api/mcp')"
|
||||
if bashio::config.has_value 'ha_mcp_token'; then
|
||||
HA_MCP_TOKEN="$(bashio::config 'ha_mcp_token')"
|
||||
fi
|
||||
@@ -325,12 +328,73 @@ if bashio::config.true 'enable_ha_mcp'; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Which of the managed MCP servers each client gets.
|
||||
#
|
||||
# Every stdio MCP server is a separate process *per client*, and Claude Desktop starts another
|
||||
# full set for each Claude Code session it hosts — so a server registered in both clients is
|
||||
# paid for several times over. Measured on a live add-on with three sets running, the private
|
||||
# (non-shared) resident cost was roughly 54 MB per extra `headroom mcp serve`, 45 MB per extra
|
||||
# `mcp-proxy`, and only ~11 MB and ~2 MB for `codex` and `tokensave`, which share most of their
|
||||
# pages. Registering a server only where it is actually used is therefore the cheapest lever
|
||||
# available; these two options expose that choice.
|
||||
#
|
||||
# Defaults keep every enabled server in both clients, i.e. the pre-existing behaviour.
|
||||
MCP_ALL_SERVERS="headroom tokensave homeassistant codex"
|
||||
|
||||
mcp_client_list() {
|
||||
local option="$1"
|
||||
local selected=() entry raw rc=0
|
||||
|
||||
# An option that is absent entirely — i.e. an existing install upgrading from a config that
|
||||
# predates these options — keeps the previous behaviour of registering every enabled server.
|
||||
# bashio distinguishes this from an explicitly empty list: an unset key yields the literal
|
||||
# "null", while `[]` yields an empty string. Those must not be conflated, because an empty
|
||||
# list is a legitimate way to say "no MCP servers in this client" and defaulting it back to
|
||||
# all four would silently ignore the user.
|
||||
if ! bashio::config.exists "$option"; then
|
||||
echo "$MCP_ALL_SERVERS"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Capture first: reading a bashio list straight into `while read` via process substitution
|
||||
# silently yields nothing under this script's errexit. The exit status is kept separately
|
||||
# so that a failed read is not mistaken for a deliberate empty selection.
|
||||
raw="$(bashio::config "$option" 2> /dev/null)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
bashio::log.warning "Could not read '${option}'; registering every enabled MCP server for this client"
|
||||
echo "$MCP_ALL_SERVERS"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while read -r entry; do
|
||||
[ -n "$entry" ] || continue
|
||||
# Reconciliation deletes any managed server not named here, so an unrecognised value
|
||||
# must never be treated as an authoritative selection.
|
||||
case " $MCP_ALL_SERVERS " in
|
||||
*" $entry "*) selected+=("$entry") ;;
|
||||
*)
|
||||
bashio::log.warning "Ignoring unknown MCP server '${entry}' in '${option}'; registering every enabled server for this client"
|
||||
echo "$MCP_ALL_SERVERS"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
|
||||
echo "${selected[@]:-}"
|
||||
}
|
||||
|
||||
MCP_SERVERS_DESKTOP="$(mcp_client_list 'mcp_servers_desktop')"
|
||||
MCP_SERVERS_CODE="$(mcp_client_list 'mcp_servers_code')"
|
||||
bashio::log.info "MCP servers for Claude Desktop: ${MCP_SERVERS_DESKTOP}"
|
||||
bashio::log.info "MCP servers for Claude Code: ${MCP_SERVERS_CODE}"
|
||||
|
||||
HEADROOM_ENABLED="$HEADROOM_ENABLED" HEADROOM_BIN="$(command -v headroom || echo headroom)" \
|
||||
HEADROOM_HF_HOME="${HOME}/.headroom/hf" \
|
||||
TOKENSAVE_ENABLED="$TOKENSAVE_ENABLED" TOKENSAVE_BIN="$(command -v tokensave || echo tokensave)" \
|
||||
CODEX_ENABLED="$CODEX_ENABLED" CODEX_BIN="$CODEX_BIN" CODEX_SANDBOX_MODE="$CODEX_SANDBOX_MODE" \
|
||||
HA_MCP_ENABLED="$HA_MCP_ENABLED" HA_MCP_URL="$HA_MCP_URL" HA_MCP_TOKEN="$HA_MCP_TOKEN" \
|
||||
MCP_PROXY_BIN="$(command -v mcp-proxy || echo mcp-proxy)" \
|
||||
MCP_SERVERS_DESKTOP="$MCP_SERVERS_DESKTOP" MCP_SERVERS_CODE="$MCP_SERVERS_CODE" \
|
||||
CLAUDE_DESKTOP_CONFIG="$CLAUDE_DESKTOP_CONFIG" CLAUDE_CODE_CONFIG="$CLAUDE_CODE_CONFIG" \
|
||||
python3 - <<'PY' || bashio::log.warning "Unable to update the MCP server registrations automatically"
|
||||
import json
|
||||
@@ -385,6 +449,23 @@ if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
"env": {"API_ACCESS_TOKEN": os.environ["HA_MCP_TOKEN"]},
|
||||
}
|
||||
|
||||
# Claude Code speaks Streamable HTTP MCP natively, so pointing it straight at Home Assistant
|
||||
# removes the mcp-proxy bridge process entirely — it exists only to translate stdio to the HTTP
|
||||
# transport Home Assistant already serves. That bridge was the most expensive duplicate
|
||||
# measured (~45 MB of private RSS per copy, one per Claude Code session).
|
||||
#
|
||||
# Claude Desktop keeps the stdio bridge. Its bundled MCP SDK does contain a remote transport,
|
||||
# but the shape `claude_desktop_config.json` accepts for a remote entry — and whether it
|
||||
# persists a static bearer header — could not be confirmed, and a wrong guess would silently
|
||||
# break Home Assistant access in Desktop. Revisit once that schema is verified upstream.
|
||||
HA_MCP_CODE_ENTRY = None
|
||||
if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
HA_MCP_CODE_ENTRY = {
|
||||
"type": "http",
|
||||
"url": os.environ["HA_MCP_URL"],
|
||||
"headers": {"Authorization": "Bearer " + os.environ["HA_MCP_TOKEN"]},
|
||||
}
|
||||
|
||||
# An entry is add-on-managed when its command is one of our binaries living outside the
|
||||
# persistent home. Matching on the basename (rather than the exact path recorded at write
|
||||
# time) keeps entries updatable when a base-image upgrade moves the binary, while commands
|
||||
@@ -392,17 +473,77 @@ if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
HOME_PREFIX = os.path.expanduser("~") + os.sep
|
||||
|
||||
|
||||
def is_managed(name, entry):
|
||||
# Ownership record for the HTTP Home Assistant entry.
|
||||
#
|
||||
# The stdio entries can be recognised on sight, because their `command` points at a binary this
|
||||
# image installs outside $HOME. An HTTP entry has no such tell: it is just a URL plus a bearer
|
||||
# header, and a user who configured `homeassistant` by hand — very plausibly at the same default
|
||||
# http://homeassistant:8123/api/mcp — would be indistinguishable from ours. Inferring ownership
|
||||
# from shape or URL would let this script delete or overwrite that entry, including their token.
|
||||
#
|
||||
# So ownership is recorded rather than guessed: the URL of an entry this script actually wrote is
|
||||
# remembered here, and only an entry matching that record is ever modified or removed. Anything
|
||||
# this script did not write is untouchable, whatever it looks like. The file holds no secrets —
|
||||
# just the endpoint — but is written 0600 to match the configs it describes.
|
||||
STATE_PATH = Path(os.path.expanduser("~")) / ".config" / "claude_desktop_addon" / "managed-mcp.json"
|
||||
|
||||
|
||||
def load_state():
|
||||
try:
|
||||
state = json.loads(STATE_PATH.read_text())
|
||||
return state if isinstance(state, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state):
|
||||
try:
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
||||
STATE_PATH.chmod(0o600)
|
||||
except Exception:
|
||||
# Losing the record only costs us the ability to clean up later; never fail the boot.
|
||||
pass
|
||||
|
||||
|
||||
def is_managed_http_ha(entry, owned_url):
|
||||
"""True only for an HTTP entry this script previously wrote."""
|
||||
return (
|
||||
bool(owned_url)
|
||||
and entry.get("url") == owned_url
|
||||
and set(entry) == {"type", "url", "headers"}
|
||||
and entry.get("type") == "http"
|
||||
and isinstance(entry.get("headers"), dict)
|
||||
and set(entry["headers"]) == {"Authorization"}
|
||||
)
|
||||
|
||||
|
||||
def is_managed(name, entry, owned_url):
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
command = entry.get("command")
|
||||
if not isinstance(command, str) or command.startswith(HOME_PREFIX):
|
||||
# A commandless entry is ours only when it is an HTTP Home Assistant registration this
|
||||
# script recorded writing. Anything else — including a user's own remote server that
|
||||
# reuses the name, even on the same URL — is left alone.
|
||||
if command is None and name == "homeassistant":
|
||||
return is_managed_http_ha(entry, owned_url)
|
||||
return False
|
||||
return os.path.basename(command) == MANAGED_BASENAMES[name]
|
||||
|
||||
|
||||
SELECTED = {
|
||||
"CLAUDE_DESKTOP_CONFIG": set(os.environ["MCP_SERVERS_DESKTOP"].split()),
|
||||
"CLAUDE_CODE_CONFIG": set(os.environ["MCP_SERVERS_CODE"].split()),
|
||||
}
|
||||
|
||||
state = load_state()
|
||||
state_changed = False
|
||||
|
||||
for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_CONFIG", True)):
|
||||
path = Path(os.environ[config_var])
|
||||
selected = SELECTED[config_var]
|
||||
owned_url = state.get(str(path), {}).get("homeassistant_http_url", "")
|
||||
try:
|
||||
data = json.loads(path.read_text()) if path.exists() else {}
|
||||
if not isinstance(data, dict):
|
||||
@@ -417,17 +558,35 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C
|
||||
changed = False
|
||||
for name in MANAGED_BASENAMES:
|
||||
existing = servers.get(name)
|
||||
if name in desired:
|
||||
entry = dict(desired[name])
|
||||
if stdio_type:
|
||||
entry["type"] = "stdio"
|
||||
if existing is None or is_managed(name, existing):
|
||||
if name in desired and name in selected:
|
||||
if stdio_type and name == "homeassistant" and HA_MCP_CODE_ENTRY is not None:
|
||||
# Claude Code talks to Home Assistant over HTTP directly; no bridge process.
|
||||
entry = dict(HA_MCP_CODE_ENTRY)
|
||||
else:
|
||||
entry = dict(desired[name])
|
||||
if stdio_type:
|
||||
entry["type"] = "stdio"
|
||||
# An entry we did not write is never overwritten, so a user's own HTTP
|
||||
# `homeassistant` survives even when it sits on the configured URL.
|
||||
claimable = existing is None or is_managed(name, existing, owned_url)
|
||||
if claimable:
|
||||
if existing != entry:
|
||||
servers[name] = entry
|
||||
changed = True
|
||||
elif existing is not None and is_managed(name, existing):
|
||||
if name == "homeassistant" and entry.get("type") == "http":
|
||||
if owned_url != entry["url"]:
|
||||
state.setdefault(str(path), {})["homeassistant_http_url"] = entry["url"]
|
||||
owned_url = entry["url"]
|
||||
state_changed = True
|
||||
elif existing is not None and is_managed(name, existing, owned_url):
|
||||
# Covers both "feature disabled" and "deselected for this client".
|
||||
del servers[name]
|
||||
changed = True
|
||||
if name == "homeassistant" and state.get(str(path), {}).pop(
|
||||
"homeassistant_http_url", None
|
||||
):
|
||||
owned_url = ""
|
||||
state_changed = True
|
||||
if changed:
|
||||
if servers:
|
||||
data["mcpServers"] = servers
|
||||
@@ -440,6 +599,9 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C
|
||||
# permissions between merges.
|
||||
if path.exists():
|
||||
path.chmod(0o600)
|
||||
|
||||
if state_changed:
|
||||
save_state(state)
|
||||
PY
|
||||
|
||||
# Guide Claude to actually use the Headroom compression tools so the MCP integration produces
|
||||
|
||||
@@ -15,6 +15,26 @@ set -e
|
||||
# regardless, so every boot picks up the current /defaults/autostart content; ownership/mode
|
||||
# is left in the normal abc-writable state that init-selkies-config itself uses when
|
||||
# RESTART_APP is unset, and re-locked by that oneshot afterward if RESTART_APP is set.
|
||||
# The autostart decides whether to hand Chromium the ANGLE/EGL flags, but it runs as abc under
|
||||
# openbox where bashio is not available. Publish the resolved option to a file it can read.
|
||||
# /run is tmpfs, so this is rewritten on every boot and never goes stale.
|
||||
GPU_MODE="$(bashio::config 'gpu_acceleration' 'auto')"
|
||||
case "$GPU_MODE" in
|
||||
auto | on | off) ;;
|
||||
*)
|
||||
bashio::log.warning "Unknown gpu_acceleration '${GPU_MODE}'; falling back to auto"
|
||||
GPU_MODE="auto"
|
||||
;;
|
||||
esac
|
||||
# Best-effort: the autostart falls back to "auto" when the file is absent, so a failure here
|
||||
# must not abort this script under errexit and leave the openbox autostart unsynced.
|
||||
if printf '%s\n' "$GPU_MODE" > /run/claude-desktop-gpu-mode 2> /dev/null; then
|
||||
chmod 0644 /run/claude-desktop-gpu-mode
|
||||
bashio::log.info "GPU acceleration mode: ${GPU_MODE}"
|
||||
else
|
||||
bashio::log.warning "Could not write /run/claude-desktop-gpu-mode; Claude Desktop will probe for GPU support (auto)"
|
||||
fi
|
||||
|
||||
if [ -f /defaults/autostart ]; then
|
||||
mkdir -p "$HOME/.config/openbox"
|
||||
cp -f /defaults/autostart "$HOME/.config/openbox/autostart"
|
||||
|
||||
196
claude_desktop/rootfs/usr/local/bin/claude-gpu-probe
Executable file
196
claude_desktop/rootfs/usr/local/bin/claude-gpu-probe
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decide whether Claude Desktop can render on the GPU on this host.
|
||||
|
||||
Claude Desktop is Electron/Chromium. Left to itself under Xvfb it probes GLX, finds
|
||||
only the indirect/software path Xvfb offers, and gives up: the GPU process ends up
|
||||
running `--use-gl=disabled` and the renderer `--disable-gpu-compositing`, so every
|
||||
frame is rastered and composited on the CPU. On a small Home Assistant host that is
|
||||
the single largest CPU consumer the add-on has.
|
||||
|
||||
The fix is to point Chromium at ANGLE's OpenGL backend over EGL instead of GLX, but
|
||||
those flags are only safe where they actually work — forcing them on a host with no
|
||||
render node, or where Mesa falls back to a software rasterizer, trades a working
|
||||
software desktop for a black window or a GPU-process crash loop.
|
||||
|
||||
So rather than guessing from the presence of /dev/dri, this probe exercises the exact
|
||||
code path Chromium will use: it loads Claude Desktop's *own bundled ANGLE* libEGL,
|
||||
initializes the OpenGL backend, creates a real pbuffer context, and reads GL_RENDERER
|
||||
back. Exit 0 means Chromium's GL stack is known-good here; any other exit means the
|
||||
caller must leave Chromium alone and keep today's software rendering.
|
||||
|
||||
Notes for future readers:
|
||||
* ANGLE's OpenGL backend needs a reachable X display, so this must run after Xorg is
|
||||
up (i.e. from the openbox autostart, not from cont-init.d).
|
||||
* `gles-egl` is deliberately not attempted: Mesa reports "Intel or NVIDIA OpenGL ES
|
||||
drivers are not supported" and ANGLE refuses to initialize.
|
||||
* A renderer string naming SwiftShader/llvmpipe/softpipe is a *failure* here. That is
|
||||
software rendering wearing a GL hat, and forcing the flags for it would add ANGLE
|
||||
translation overhead on top of the CPU rasterization we are trying to avoid.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import sys
|
||||
|
||||
LIBDIR = "/usr/lib/claude-desktop"
|
||||
|
||||
# EGL/ANGLE constants (see ANGLE's eglext.h); hardcoded to avoid a build dependency.
|
||||
EGL_NONE = 0x3038
|
||||
EGL_PLATFORM_ANGLE_ANGLE = 0x3202
|
||||
EGL_PLATFORM_ANGLE_TYPE_ANGLE = 0x3203
|
||||
EGL_PLATFORM_ANGLE_TYPE_OPENGL_ANGLE = 0x320D
|
||||
EGL_OPENGL_ES_API = 0x30A0
|
||||
EGL_SURFACE_TYPE = 0x3033
|
||||
EGL_PBUFFER_BIT = 0x0001
|
||||
EGL_RENDERABLE_TYPE = 0x3040
|
||||
EGL_OPENGL_ES2_BIT = 0x0004
|
||||
EGL_WIDTH = 0x3057
|
||||
EGL_HEIGHT = 0x3056
|
||||
EGL_CONTEXT_CLIENT_VERSION = 0x3098
|
||||
GL_VENDOR = 0x1F00
|
||||
GL_RENDERER = 0x1F01
|
||||
|
||||
SOFTWARE_MARKERS = ("swiftshader", "llvmpipe", "softpipe", "lavapipe", "software rasterizer")
|
||||
|
||||
|
||||
class ProbeFailure(Exception):
|
||||
"""Raised when this host cannot give Chromium a hardware GL context."""
|
||||
|
||||
|
||||
def log(message):
|
||||
"""Write a probe diagnostic to stderr, where it lands in the add-on log."""
|
||||
sys.stderr.write(f"claude-gpu-probe: {message}\n")
|
||||
|
||||
|
||||
def load_angle():
|
||||
"""Load Claude Desktop's bundled ANGLE and declare the signatures we call."""
|
||||
egl_path = os.path.join(LIBDIR, "libEGL.so")
|
||||
gles_path = os.path.join(LIBDIR, "libGLESv2.so")
|
||||
if not (os.path.exists(egl_path) and os.path.exists(gles_path)):
|
||||
raise ProbeFailure(f"bundled ANGLE libraries not found under {LIBDIR}")
|
||||
|
||||
try:
|
||||
egl = ctypes.CDLL(egl_path, mode=ctypes.RTLD_GLOBAL)
|
||||
gles = ctypes.CDLL(gles_path, mode=ctypes.RTLD_GLOBAL)
|
||||
except OSError as err:
|
||||
raise ProbeFailure(f"could not load bundled ANGLE: {err}") from err
|
||||
|
||||
egl.eglGetProcAddress.restype = ctypes.c_void_p
|
||||
egl.eglGetError.restype = ctypes.c_int
|
||||
egl.eglInitialize.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
]
|
||||
egl.eglCreatePbufferSurface.restype = ctypes.c_void_p
|
||||
egl.eglCreateContext.restype = ctypes.c_void_p
|
||||
gles.glGetString.restype = ctypes.c_char_p
|
||||
gles.glGetString.argtypes = [ctypes.c_uint]
|
||||
return egl, gles
|
||||
|
||||
|
||||
def open_angle_display(egl):
|
||||
"""Initialize ANGLE's OpenGL backend and return its EGL display."""
|
||||
addr = egl.eglGetProcAddress(b"eglGetPlatformDisplayEXT")
|
||||
if not addr:
|
||||
raise ProbeFailure("bundled ANGLE has no eglGetPlatformDisplayEXT")
|
||||
get_platform_display = ctypes.CFUNCTYPE(
|
||||
ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)
|
||||
)(addr)
|
||||
|
||||
attrs = (ctypes.c_int * 3)(
|
||||
EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_OPENGL_ANGLE, EGL_NONE
|
||||
)
|
||||
display = get_platform_display(EGL_PLATFORM_ANGLE_ANGLE, None, attrs)
|
||||
if not display:
|
||||
raise ProbeFailure(f"no ANGLE OpenGL display (egl error 0x{egl.eglGetError():x})")
|
||||
|
||||
major, minor = ctypes.c_int(), ctypes.c_int()
|
||||
if not egl.eglInitialize(ctypes.c_void_p(display), ctypes.byref(major), ctypes.byref(minor)):
|
||||
raise ProbeFailure(
|
||||
f"ANGLE OpenGL backend failed to initialize (egl error 0x{egl.eglGetError():x})"
|
||||
)
|
||||
return display
|
||||
|
||||
|
||||
def make_current_context(egl, display):
|
||||
"""Bring up a real pbuffer context.
|
||||
|
||||
Initialization alone is not proof of anything: only a current context makes
|
||||
GL_RENDERER report the driver Chromium would actually be handed.
|
||||
"""
|
||||
egl.eglBindAPI(EGL_OPENGL_ES_API)
|
||||
config = ctypes.c_void_p()
|
||||
count = ctypes.c_int()
|
||||
config_attrs = (ctypes.c_int * 5)(
|
||||
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_NONE
|
||||
)
|
||||
chosen = egl.eglChooseConfig(
|
||||
ctypes.c_void_p(display), config_attrs, ctypes.byref(config), 1, ctypes.byref(count)
|
||||
)
|
||||
if not chosen or count.value == 0:
|
||||
raise ProbeFailure(f"no usable EGL config (egl error 0x{egl.eglGetError():x})")
|
||||
|
||||
surface_attrs = (ctypes.c_int * 5)(EGL_WIDTH, 64, EGL_HEIGHT, 64, EGL_NONE)
|
||||
surface = egl.eglCreatePbufferSurface(ctypes.c_void_p(display), config, surface_attrs)
|
||||
if not surface:
|
||||
raise ProbeFailure(f"could not create pbuffer surface (egl error 0x{egl.eglGetError():x})")
|
||||
|
||||
context_attrs = (ctypes.c_int * 3)(EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE)
|
||||
context = egl.eglCreateContext(ctypes.c_void_p(display), config, None, context_attrs)
|
||||
if not context:
|
||||
raise ProbeFailure(f"could not create GL context (egl error 0x{egl.eglGetError():x})")
|
||||
|
||||
if not egl.eglMakeCurrent(
|
||||
ctypes.c_void_p(display),
|
||||
ctypes.c_void_p(surface),
|
||||
ctypes.c_void_p(surface),
|
||||
ctypes.c_void_p(context),
|
||||
):
|
||||
raise ProbeFailure(
|
||||
f"could not make the GL context current (egl error 0x{egl.eglGetError():x})"
|
||||
)
|
||||
|
||||
|
||||
def describe_renderer(gles):
|
||||
"""Return (renderer, vendor), rejecting software rasterizers."""
|
||||
renderer = (gles.glGetString(GL_RENDERER) or b"").decode(errors="replace")
|
||||
vendor = (gles.glGetString(GL_VENDOR) or b"").decode(errors="replace")
|
||||
if not renderer:
|
||||
raise ProbeFailure("GL context reported no renderer")
|
||||
|
||||
lowered = renderer.lower()
|
||||
if any(marker in lowered for marker in SOFTWARE_MARKERS):
|
||||
raise ProbeFailure(
|
||||
f"software renderer ({renderer}); leaving Chromium on its own software path"
|
||||
)
|
||||
return renderer, vendor
|
||||
|
||||
|
||||
def main():
|
||||
"""Exit 0 only when Chromium's GL stack is known-good on this host."""
|
||||
if not os.environ.get("DISPLAY"):
|
||||
log("no DISPLAY; ANGLE's OpenGL backend needs an X server")
|
||||
return 1
|
||||
|
||||
try:
|
||||
egl, gles = load_angle()
|
||||
display = open_angle_display(egl)
|
||||
make_current_context(egl, display)
|
||||
renderer, vendor = describe_renderer(gles)
|
||||
except ProbeFailure as err:
|
||||
log(str(err))
|
||||
return 1
|
||||
# A probe is advisory: whatever goes wrong in these native calls, the desktop must still
|
||||
# start. Any unexpected failure is reported and treated as "no GPU".
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as err:
|
||||
log(f"unexpected probe error: {err}")
|
||||
return 1
|
||||
|
||||
log(f"hardware GL available: {renderer} | {vendor}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user