Compare commits

..

10 Commits

Author SHA1 Message Date
GitHub Actions
49dd9f0e4e Revert "Migrate legacy addon_config maps to app_config (#2933)"
This reverts commit d3d3476986.
2026-08-03 14:22:18 +00:00
GitHub Actions
b453c60c33 Revert "GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild]"
This reverts commit aceeaf36b4.
2026-08-03 14:22:18 +00:00
github-actions
aceeaf36b4 GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild] 2026-08-03 13:42:52 +00:00
Alexandre
9faae5cc90 perf(claude_desktop): render on the GPU and stop duplicating MCP servers (#2934)
* perf(claude_desktop): render on the GPU and stop duplicating MCP servers

Measured live inside a running add-on (amd64, 4 cores): 3388 MB RSS across
73 processes, with the Electron renderer burning ~44% of a core even with no
browser client connected.

GPU: Chromium was rendering everything on the CPU. Under Xvfb it probes GLX,
finds only Xvfb's indirect/software path, and falls back to `--use-gl=disabled`
plus `--disable-gpu-compositing` — while a perfectly good iGPU sits idle behind
/dev/dri. Claude Desktop is now launched through ANGLE's OpenGL backend over
EGL, but only when the new claude-gpu-probe confirms that Desktop's own bundled
ANGLE can create a hardware GL context on this host; the probe rejects
llvmpipe/SwiftShader, is bounded by a timeout, and any failure leaves the
command line exactly as it was. New `gpu_acceleration` option (auto|on|off).

MCP: every stdio MCP server is a separate process per client, and Desktop
starts another full set for each Claude Code session it hosts. Claude Code now
reaches the Home Assistant MCP server over its native HTTP transport instead of
the mcp-proxy stdio bridge, removing the most expensive duplicate (~45 MB of
private RSS per copy). Desktop keeps the bridge: its remote-entry config schema
could not be confirmed, and guessing would silently break it. New
`mcp_servers_desktop` / `mcp_servers_code` options let each client register only
what it actually uses; defaults are unchanged.

Display: new `max_resolution` option (default 1920x1080) caps the virtual screen
via the base image's MAX_RES. Xvfb ran at 15360x8640, so it and the Selkies
capture loop tracked damage over a 133-megapixel area continuously. This is a
CPU saving, not a memory one — the framebuffer is a lazily populated shared
segment whose unused portion was never resident.

Dockerfile: the Intel graphics block was dead code. It was gated on TARGETARCH,
which this 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, and its apt history
contains no matching install. It now uses BUILD_ARCH, and its Vulkan ICD check
no longer names intel_icd.x86_64.json, a file Debian does not ship.

Also removes `--disable-dev-shm-usage` (a workaround for a 64 MB /dev/shm; this
image has 7.7 GB) and fixes stale Home Assistant MCP registrations, including
the bearer token inside them, being left behind when enable_ha_mcp is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude_desktop): satisfy static analysis in claude-gpu-probe

Codacy flagged two new issues, both in the probe: a broad exception catch and
too many locals in main(). Split the EGL bring-up into load_angle(),
open_angle_display(), make_current_context() and describe_renderer(), each
raising a dedicated ProbeFailure, so the failure paths read as intent rather
than as a chain of early returns. The catch-all remains — a probe must never
stop the desktop from starting — but is now explicit and narrowly scoped.

No behaviour change: exits 0 with a hardware renderer under DISPLAY, 1 without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude_desktop): address PR review on shm and MCP entry ownership

Two review findings, both correct.

/dev/shm: dropping --disable-dev-shm-usage outright was generalised from one
host. The flag was added to fix a real Electron renderer crash loop on Docker's
64 MB default, and Home Assistant ignores the add-on's shm_size, so the size
genuinely varies per install and cannot be asserted from this repo. The size is
now read at startup: the workaround is kept below 256 MB, dropped above it, and
kept when the size cannot be determined.

MCP ownership: claiming an HTTP 'homeassistant' entry by URL and shape would
have deleted a user's own manually configured server on the first boot after
upgrade, since ha_mcp_url defaults to the same public endpoint that a hand-
written entry would use, and enable_ha_mcp defaults to false. An HTTP entry is
now only ever modified or removed when the add-on recorded writing it, in
~/.config/claude_desktop_addon/managed-mcp.json. Anything not written by the
add-on is untouchable regardless of how it looks.

Also drops the invalid '?' optional marker from the list *item* type in the
mcp_servers_* schema; both keys always carry defaults, so the marker was
meaningless as well as wrong.

Tests cover the regression directly: a user-owned HTTP entry on the default URL
now survives both a disabled and an enabled boot, while the add-on's own entry
is still removed with its token when enable_ha_mcp is turned off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude_desktop): tighten max_resolution validation and reporting

grep anchors ^...$ per line, so a multi-line max_resolution such as
"1920x1080\n640x480" passed validation on its first line and was then written
to MAX_RES verbatim, leaving svc-xorg with a corrupt screen size. Bash's =~
anchors the whole string and rejects the embedded newline.

Also stop reporting success when no s6 environment directory existed and
nothing was written — the cap silently did not apply, and the log said it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:27:30 +02:00
Alexandre
d3d3476986 Migrate legacy addon_config maps to app_config (#2933)
* Run app config map migration

* Migrate app config map names

* Add one-shot workflow to address PR 2933 review comments

* Trigger PR 2933 review-fix workflow

* Temporarily apply PR 2933 review fixes

* Restore lint workflow

* Remove temporary review-fix workflow

* Fix Cleanuparr app_config persistence path

* Document Cleanuparr app_config mount

* Fix qBittorrent migration paths and messages

* Correct qbit_manage configuration filename

* Align custom script assignment indentation

* Update Mealie app_config documentation path

* Apply remaining PR 2933 review fixes

* Correct Mealie historical path count

* Commit review fixes without workflow changes

* Address remaining PR review comments

* Restore lint workflow after review fixes

* Document Webtrees app-config host folder

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-03 15:24:16 +02:00
github-actions
c0aa5af0ce Github bot : image compressed 2026-08-02 23:22:36 +00:00
github-actions
87c9b60be0 GitHub bot: changelog [nobuild] 2026-08-02 15:59:48 +00:00
Alexandre
82ac7957c8 Update version in config.yaml 2026-08-02 17:57:45 +02:00
Alexandre
f0d20d77fa Update config.yaml 2026-08-02 17:57:20 +02:00
Alexandre
988cecb122 fix(birdnet-pi): restore the ingress Caddy site (502 Bad Gateway) (#2931)
* fix(birdnet-pi): restore the ingress Caddy site, ingress returned 502

nginx forwards ingress traffic to 127.0.0.1:8082 (rootfs/etc/nginx/servers/
ingress.conf:11), a site appended to the Caddyfile by helpers/caddy_ingress.sh.
The upstream script $HOME/BirdNET-Pi/scripts/update_caddyfile.sh regenerates
/etc/caddy/Caddyfile from scratch and 02-caddy.sh runs it immediately before
`exec caddy run`, so 91-nginx_ingress.sh injected a call to caddy_ingress.sh
into that script to re-add the site. The injection was anchored on
`sudo caddy fmt --overwrite`.

Since 2026.07.10-1 the Dockerfile strips `sudo ` from every BirdNET-Pi script at
build time (Dockerfile:110), so the shipped line is `caddy fmt --overwrite` and
the anchor stopped matching. sed reports success when a pattern matches nothing,
so this failed silently: Caddy came up listening only on :8081 and every ingress
request got connection-refused on 8082. Confirmed by extracting the script from
the published ghcr.io/alexbelgium/birdnet-pi-amd64 image - it contains no `sudo`.

- 91-nginx_ingress.sh: make the anchor accept the line with or without `sudo`,
  skip the injection when it is already there (cont-init re-runs on restart),
  and verify afterwards, falling back to appending the call if the anchor is
  ever gone again.
- caddy_ingress.sh: return early when a `:8082` site already exists. The script
  now runs from more than one place, and a duplicate site address makes Caddy
  refuse to start.
- 02-caddy.sh: re-add the ingress site just before starting Caddy if it is
  missing, so a future upstream change to update_caddyfile.sh cannot silently
  bring back the 502.
- 91-nginx_ingress.sh: drop /ingress_url when ingress is off, so that marker is
  a truthful signal for the check above even across an in-container restart.

Verified with a harness that replays the boot sequence (build-time sudo strip,
81-modifications.sh, 91-nginx_ingress.sh, 02-caddy.sh) against the real upstream
update_caddyfile.sh: master ends with no :8082 site, this branch ends with
exactly one, on fresh boot, on restart, and when the `caddy fmt` anchor is
removed entirely; standalone mode still gets no ingress site.

Closes #2928

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(birdnet-pi): harden the ingress-site recovery check in 02-caddy.sh

Review feedback on the pre-start check:

- Require /etc/caddy/Caddyfile to exist and silence grep's stderr. `! grep` is
  also true for grep's exit code 2, so an unreadable or missing Caddyfile read
  as "ingress site missing" and would have produced an ingress-only Caddyfile
  out of an error state. Letting caddy fail on the missing config is easier to
  diagnose.
- Report a failure of caddy_ingress.sh instead of swallowing it. Do not exit:
  /custom-services.d scripts are LSIO longruns that s6 restarts when they
  return, so exiting would flap the service and take the WebUI down on 8081 as
  well, which is strictly worse than ingress alone being broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:56:19 +02:00
146 changed files with 662 additions and 701 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 60 KiB

BIN
.github/stats.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +1,7 @@
## 2026.08.02 (02-08-2026)
- Fix: ingress returned "502 Bad Gateway" because Caddy never listened on :8082. `91-nginx_ingress.sh` hooked the ingress site into `update_caddyfile.sh` with a sed anchored on `sudo caddy fmt --overwrite`, but 2026.07.10-1 strips `sudo` from every BirdNET-Pi script at build time, so the anchor stopped matching. `update_caddyfile.sh` then rewrote the Caddyfile from scratch just before Caddy started, dropping the ingress site
- Fix: `caddy_ingress.sh` no longer appends a second `:8082` block when it runs twice (a duplicate site address makes Caddy refuse to start)
- Fix: `02-caddy.sh` re-adds the ingress site if it is missing from the Caddyfile just before starting Caddy
## 2026.07.22 (22-07-2026)
- Fix: health-check the WebUI port (8081) instead of port 80, so the standalone Docker container no longer reports "unhealthy" when ssl=false
- Fix: health-check now probes https when ssl is enabled, and no longer silently reports "healthy" regardless of the actual result (the previous check's `&>` redirection is a bash-ism that dash, the image's /bin/sh, parses as background + no-op, discarding curl's exit status)

View File

@@ -116,5 +116,5 @@ tmpfs: true
udev: true
url: https://github.com/alexbelgium/hassio-addons/tree/master/birdnet-pi
usb: true
version: 2026.07.22
version: 2026.08.02
video: true

View File

@@ -25,5 +25,24 @@ export TZ="${TZ_VALUE:-Etc/UTC}"
# Update caddyfile with password
"$HOME"/BirdNET-Pi/scripts/update_caddyfile.sh &> /dev/null || true
# update_caddyfile.sh rewrites the Caddyfile from scratch. 91-nginx_ingress.sh
# hooks the ingress site back into it, but if that hook ever fails to apply,
# caddy would start without a :8082 listener and ingress would answer 502.
# 91-nginx_ingress.sh writes /ingress_url when ingress is on and removes it when
# it is off, so this is a no-op in standalone mode.
# Require the Caddyfile to exist: if it is missing something went badly wrong
# earlier, and caddy failing on a missing config is easier to diagnose than an
# ingress-only Caddyfile conjured up here.
if [[ -f /ingress_url ]] && [[ -f /etc/caddy/Caddyfile ]] \
&& ! grep -qE '^[[:space:]]*:8082[[:space:]]*\{' /etc/caddy/Caddyfile 2> /dev/null; then
echo "Ingress site missing from the Caddyfile, re-adding it"
if ! /helpers/caddy_ingress.sh; then
# Start caddy anyway: ingress stays broken, but direct access on 8081
# keeps working. Exiting here would only make s6 restart this service in
# a loop and take the WebUI down completely.
echo "Failed to re-add the ingress site, the ingress panel will return 502" >&2
fi
fi
echo "Starting service: caddy"
exec /usr/bin/caddy run --config /etc/caddy/Caddyfile

View File

@@ -26,6 +26,10 @@ ingress_entry=$(bashio::addon.ingress_entry)
if [[ "$ingress_entry" != "/api"* ]]; then
bashio::log.info "Ingress entry is not set, exiting configuration."
sed -i "1a sleep infinity" /custom-services.d/02-nginx.sh
# This script re-runs from /etc/scripts-init on an add-on restart, so drop
# any marker left by an earlier run: it is what 02-caddy.sh reads to decide
# whether the ingress site belongs in the Caddyfile.
rm -f /ingress_url
exit 0
fi
@@ -68,9 +72,23 @@ sed -i "s|localhost|localhost:8082|g" "$HOME/BirdNET-Pi/scripts/utils/notificati
# Update the Caddyfile if update script exists
caddy_update_script="$HOME/BirdNET-Pi/scripts/update_caddyfile.sh"
if [ -f "$caddy_update_script" ]; then
sed -i "/sudo caddy fmt --overwrite/i /helpers/caddy_ingress.sh" "$caddy_update_script"
else
if [ ! -f "$caddy_update_script" ]; then
bashio::log.error "Caddy update script not found: $caddy_update_script"
exit 1
fi
# update_caddyfile.sh rewrites /etc/caddy/Caddyfile from scratch, which drops
# the ingress site added just above. 02-caddy.sh runs it right before starting
# caddy, so the hook below has to re-add the site from inside that script, just
# before it formats and reloads the config.
# The anchor must not require "sudo": the Dockerfile strips it from every
# BirdNET-Pi script at build time, so the shipped line is "caddy fmt --overwrite".
if ! grep -qF "/helpers/caddy_ingress.sh" "$caddy_update_script"; then
sed -i -E "/^[[:space:]]*(sudo[[:space:]]+)?caddy[[:space:]]+fmt[[:space:]]+--overwrite/i /helpers/caddy_ingress.sh" "$caddy_update_script"
fi
# sed is silent when the anchor is missing; make sure the hook is really there
if ! grep -qF "/helpers/caddy_ingress.sh" "$caddy_update_script"; then
bashio::log.warning "Could not anchor the ingress site in $caddy_update_script, appending it instead"
printf '\n/helpers/caddy_ingress.sh\n' >> "$caddy_update_script"
fi

View File

@@ -6,6 +6,13 @@ set +u
# shellcheck disable=SC1091
source /etc/birdnet/birdnet.conf
# Nothing to do if the ingress site is already there. This script runs both from
# cont-init and from update_caddyfile.sh, and a duplicate ":8082" site address
# makes caddy refuse to start.
if grep -qE '^[[:space:]]*:8082[[:space:]]*\{' /etc/caddy/Caddyfile 2> /dev/null; then
exit 0
fi
# Create ingress configuration for Caddyfile
cat << EOF >> /etc/caddy/Caddyfile
:8082 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,9 +1,39 @@
## 1.38 (02-08-2026)
- Reduce Claude Desktop add-on RAM use in three places without removing Headroom, RTK, TokenSave, Cowork, Dispatch, or the streamed desktop. Headroom's heavy HTTP proxy is no longer started at container boot: a standard-library TCP gate stays on port 8787, starts the real proxy on the first request, and stops it after 15 minutes without traffic (`HEADROOM_IDLE_TIMEOUT_SECONDS` remains overridable through `env_vars`). This releases the proxy's Python, ONNX Runtime, tokenizer, and Kompress model allocations while the add-on is idle; a later request starts a clean backend transparently.
- Prevent Headroom from loading a second Kompress model inside every Claude Desktop/Claude Code MCP process. The add-on now intercepts `headroom mcp serve` with a lightweight adapter that retains upstream MCP retrieval/statistics behavior but delegates `headroom_compress` to the shared proxy's loopback `/v1/compress` endpoint. The MCP process explicitly disables local Kompress and never imports `headroom.compress`, so only the proxy backend can own the ML runtime.
- Remove the add-on-wide `tmpfs: true` mount and undo the shared Selkies script's `/tmp/cache` redirection for this add-on. Electron/Chromium, Mesa, and application caches now live under the selected persistent home (`$HOME/.cache`) as reclaimable filesystem cache instead of RAM-backed cgroup shmem. Runtime sockets and XDG runtime state remain under `/run`.
## 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
## ubunturesolute-version-3a10bef7 (2026-08-01)
- Update to latest version from linuxserver/docker-baseimage-selkies (changelog : https://github.com/linuxserver/docker-baseimage-selkies/releases)

View File

@@ -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 && \

View File

@@ -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`. |

View File

@@ -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: "1.38"
version: "2026.08.03"
video: true

View File

@@ -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

View 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)"

View File

@@ -1,58 +0,0 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# 20-folders.sh is shared with the Webtop add-ons and historically redirects
# XDG_CACHE_HOME to /tmp/cache. This Claude-specific follow-up runs before the
# graphical longruns and moves general application caches back under the
# persistent home. Removing config.yaml's `tmpfs: true` then ensures Chromium,
# Electron, Mesa and other cache pages are reclaimable filesystem cache instead
# of permanently charged cgroup shmem.
LOCATION="$(getent passwd abc 2> /dev/null | cut -d: -f6 || true)"
if [ -z "$LOCATION" ] || [ "$LOCATION" = "/" ]; then
bashio::log.warning "Unable to resolve abc home; leaving XDG cache configuration unchanged"
exit 0
fi
CACHE_DIR="$LOCATION/.cache"
if [ -L "$CACHE_DIR" ]; then
rm -f "$CACHE_DIR"
fi
mkdir -p "$CACHE_DIR"
chown "$(id -u abc):$(id -g abc)" "$CACHE_DIR"
chmod 700 "$CACHE_DIR"
CACHE_DIR="$CACHE_DIR" LOCATION="$LOCATION" python3 - <<'PY'
import os
import re
from pathlib import Path
cache = os.environ["CACHE_DIR"]
quoted = cache.replace("\\", "\\\\").replace('"', '\\"')
replacement = f'export XDG_CACHE_HOME="{quoted}"'
for path in Path("/etc/s6-overlay/s6-rc.d").glob("*/run"):
try:
text = path.read_text()
except (OSError, UnicodeDecodeError):
continue
updated = re.sub(r"^export XDG_CACHE_HOME=.*$", replacement, text, flags=re.MULTILINE)
if updated != text:
path.write_text(updated)
bashrc = Path(os.environ["LOCATION"]) / ".bashrc"
if bashrc.exists():
text = bashrc.read_text()
updated = re.sub(r"^export XDG_CACHE_HOME=.*$", replacement, text, flags=re.MULTILINE)
if updated != text:
bashrc.write_text(updated)
PY
S6_ENVDIR="/run/s6/container_environment"
mkdir -p "$S6_ENVDIR"
printf '%s' "$CACHE_DIR" > "$S6_ENVDIR/XDG_CACHE_HOME"
# Safe here: no graphical longrun has started yet, and the former directory was
# only a boot-created target for the now-removed persistent-home symlink.
rm -rf /tmp/cache
bashio::log.info "Application cache moved from RAM-backed /tmp to $CACHE_DIR"

View File

@@ -1,59 +0,0 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# Keep every existing Headroom CLI command unchanged, but intercept the MCP
# server entrypoint so it uses the add-on's single-runtime adapter. The real
# binary is intentionally outside /usr/local/bin; writing the wrapper there
# makes it the command 82-claude_tools.sh registers in Claude Desktop/Code.
REAL_HEADROOM=""
for candidate in /usr/bin/headroom /lsiopy/bin/headroom; do
if [ -x "$candidate" ]; then
REAL_HEADROOM="$candidate"
break
fi
done
if [ -z "$REAL_HEADROOM" ]; then
bashio::log.warning "Headroom executable was not found; MCP adapter wrapper was not installed"
exit 0
fi
# The adapter intentionally uses a small, stable subset of the upstream MCP
# server. Since Headroom is installed unpinned at image build time, verify that
# subset before replacing the command. A future incompatible Headroom release
# therefore keeps its native MCP server instead of breaking Claude startup.
if ! /lsiopy/bin/python3 - <<'PY'
from headroom.ccr.mcp_server import HeadroomMCPServer
for name in ("run_stdio", "cleanup", "_compress_content"):
if not callable(getattr(HeadroomMCPServer, name, None)):
raise SystemExit(f"HeadroomMCPServer.{name} is unavailable")
PY
then
bashio::log.warning "Installed Headroom is incompatible with the single-runtime MCP adapter; preserving the native MCP server"
exit 0
fi
wrapper="$(mktemp /usr/local/bin/.headroom-wrapper.XXXXXX)"
cleanup() {
rm -f "$wrapper"
}
trap cleanup EXIT
cat > "$wrapper" <<EOF
#!/bin/sh
REAL_HEADROOM="$REAL_HEADROOM"
if [ "\${1:-}" = "mcp" ] && [ "\${2:-}" = "serve" ]; then
shift 2
unset HF_HOME
export HEADROOM_DISABLE_KOMPRESS=1
exec /lsiopy/bin/python3 /usr/local/bin/headroom-mcp-proxy.py "\$@"
fi
exec "\$REAL_HEADROOM" "\$@"
EOF
chmod 0755 "$wrapper"
mv -f "$wrapper" /usr/local/bin/headroom
trap - EXIT
bashio::log.info "Headroom MCP compression is delegated to the shared lazy proxy runtime"

View File

@@ -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

View File

@@ -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"

View File

@@ -1,43 +1,32 @@
#!/usr/bin/with-contenv bashio
# Headroom optimization proxy — lazy backend for Claude Desktop MCP and Claude Code.
# Headroom optimization proxy — local backend for Claude Desktop MCP and Claude Code.
declare port=8787
declare backend_port=8789
declare host=127.0.0.1
# The dashboard is unauthenticated. Keep the gate container-local by default and
# bind all interfaces only when the user explicitly opts in and maps port 8787.
# The dashboard is unauthenticated. Keep it container-local by default and bind all
# interfaces only when the user explicitly opts in and maps port 8787.
if bashio::config.true 'expose_headroom_dashboard'; then
host=0.0.0.0
fi
if bashio::config.true 'install_headroom'; then
real_headroom=""
for candidate in /usr/bin/headroom /lsiopy/bin/headroom; do
if [ -x "$candidate" ]; then
real_headroom="$candidate"
break
fi
done
if [ -n "$real_headroom" ]; then
# Keep model artifacts persistent, but do not import Headroom or load the
# model in this longrun. The standard-library gate starts the real proxy
# on the first request and terminates it after the idle timeout, releasing
# Python/ONNX/model allocations. HEADROOM_IDLE_TIMEOUT_SECONDS is
# overridable through env_vars; 900 seconds is the default.
export HF_HOME="${HOME}/.headroom/hf"
mkdir -p "$HF_HOME" "${HOME}/.headroom"
chown abc:abc "${HOME}/.headroom" "$HF_HOME" 2> /dev/null || true
bashio::log.info "svc-headroom: starting lazy gate on ${host}:${port} (backend ${backend_port}, idle timeout ${HEADROOM_IDLE_TIMEOUT_SECONDS:-900}s)"
exec s6-setuidgid abc env \
HEADROOM_REAL_BIN="$real_headroom" \
HEADROOM_GATE_HOST="$host" \
HEADROOM_GATE_PORT="$port" \
HEADROOM_BACKEND_HOST=127.0.0.1 \
HEADROOM_BACKEND_PORT="$backend_port" \
HEADROOM_IDLE_TIMEOUT_SECONDS="${HEADROOM_IDLE_TIMEOUT_SECONDS:-900}" \
HF_HOME="$HF_HOME" \
/lsiopy/bin/python3 /usr/local/bin/headroom-proxy-gate.py
fi
if bashio::config.true 'install_headroom' && command -v headroom > /dev/null 2>&1; then
# Kompress (the ONNX compression engine) needs its model in the local HF cache: the
# proxy's startup preload is deliberately cache-only, and the default HF cache lands
# under ~/.cache, which the add-on points at tmpfs (/tmp/cache) — wiped on every
# restart. Without a warm persistent cache the proxy ran forever in "deferred" mode
# and recorded zero compression savings. Point the cache at persistent storage;
# nothing else is needed here — the proxy's own request path already downloads a
# missing model in the background on first use (ensure_background_load) and passes
# requests through uncompressed until it lands, so this self-heals within a couple of
# requests on the first boot and loads instantly (eager preload) on every boot after.
# A synchronous pre-warm was tried here and removed: it blocked the port bind for up
# to the download's duration, which left the settings-managed ANTHROPIC_BASE_URL
# (see 82-claude_tools.sh) pointing at a proxy that wasn't listening yet.
export HF_HOME="${HOME}/.headroom/hf"
mkdir -p "$HF_HOME"
chown abc:abc "$HF_HOME" 2> /dev/null || true
bashio::log.info "svc-headroom: starting local Headroom proxy on ${host}:${port}"
exec s6-setuidgid abc headroom proxy --host "${host}" --port "${port}" --code-aware
fi
bashio::log.info "svc-headroom: install_headroom disabled or headroom not found; idling"

View 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())

View File

@@ -1,142 +0,0 @@
#!/lsiopy/bin/python3
"""Run Headroom MCP with compression delegated to the single proxy backend.
Upstream Headroom MCP normally performs ``headroom_compress`` in its own
process. That imports the compression pipeline and can load a second copy of the
Kompress ONNX model in addition to the HTTP proxy. This adapter preserves the
upstream MCP protocol and retrieve/stats implementations, but replaces only its
local compression method with a call to the proxy's loopback-only
``/v1/compress`` endpoint.
The proxy gate starts the heavy backend on this first request and later unloads
it after the configured idle timeout. The MCP process therefore remains a
lightweight protocol bridge and never imports ``headroom.compress``.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from types import MethodType
from typing import Any
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Headroom MCP single-runtime adapter")
parser.add_argument(
"--proxy-url",
default=os.environ.get("HEADROOM_PROXY_URL", "http://127.0.0.1:8787"),
)
parser.add_argument("--transport", default="stdio")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8788)
parser.add_argument("--path", default="/mcp")
parser.add_argument("--debug", action="store_true")
parser.add_argument("--direct", action="store_true")
args, unknown = parser.parse_known_args()
if unknown:
print(f"headroom-mcp-proxy: ignoring unsupported arguments: {unknown}", file=sys.stderr)
if args.transport.lower() != "stdio":
parser.error("only stdio transport is supported by the add-on adapter")
return args
def _number(data: dict[str, Any], key: str) -> int:
value = data.get(key, 0)
try:
return int(value)
except (TypeError, ValueError):
return 0
def make_proxy_compressor(proxy_url: str):
endpoint = f"{proxy_url.rstrip('/')}/v1/compress"
model = os.environ.get("HEADROOM_MCP_MODEL", "claude-sonnet-4-5-20250929")
def compress_via_proxy(_self, content: str) -> dict[str, Any]:
# Imported here, not at module import, so MCP startup stays small. httpx is
# already a dependency of Headroom's MCP server for retrieve/stats.
import httpx
response = httpx.post(
endpoint,
json={
"messages": [{"role": "tool", "content": content}],
"model": model,
},
timeout=httpx.Timeout(180.0, connect=75.0),
)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
raise RuntimeError("Headroom proxy returned a non-object compression response")
messages = data.get("messages")
compressed: Any = content
if isinstance(messages, list) and messages:
last = messages[-1]
if isinstance(last, dict) and "content" in last:
compressed = last["content"]
if not isinstance(compressed, str):
compressed = json.dumps(compressed, ensure_ascii=False)
hashes = data.get("ccr_hashes")
hash_key = next((item for item in hashes if isinstance(item, str)), None) if isinstance(hashes, list) else None
before = _number(data, "tokens_before")
after = _number(data, "tokens_after")
saved = _number(data, "tokens_saved")
if saved <= 0:
saved = max(0, before - after)
savings_percent = round(saved / before * 100, 1) if before > 0 else 0.0
transforms = data.get("transforms_applied")
if not isinstance(transforms, list):
transforms = []
note = "Compression was executed by the shared Headroom proxy runtime."
if hash_key:
note += (
f" Original stored with hash={hash_key}. "
"Use mcp__headroom__headroom_retrieve to recover it."
)
return {
"compressed": compressed,
"hash": hash_key,
"original_tokens": before,
"compressed_tokens": after,
"tokens_saved": saved,
"savings_percent": savings_percent,
"transforms": transforms,
"note": note,
}
return compress_via_proxy
async def run() -> None:
args = parse_args()
# Defense in depth. The adapter never calls the local compressor, but keep
# the MCP process explicitly unable to initialize Kompress if upstream code
# changes or an unrelated import probes the compression pipeline.
os.environ.pop("HF_HOME", None)
os.environ["HEADROOM_DISABLE_KOMPRESS"] = "1"
os.environ["HEADROOM_PROXY_URL"] = args.proxy_url
from headroom.ccr.mcp_server import HeadroomMCPServer
server = HeadroomMCPServer(proxy_url=args.proxy_url, check_proxy=True)
server._compress_content = MethodType(make_proxy_compressor(args.proxy_url), server)
try:
await server.run_stdio()
finally:
await server.cleanup()
if __name__ == "__main__":
try:
asyncio.run(run())
except KeyboardInterrupt:
pass

View File

@@ -1,388 +0,0 @@
#!/lsiopy/bin/python3
"""Lazy TCP gate for the Headroom HTTP proxy.
The gate remains resident on the public Headroom port while the heavy Headroom
proxy (and its optional ONNX Kompress model) is started only for real traffic.
After an idle period the backend process is terminated, releasing its Python,
ONNX and model allocations. The next connection starts a fresh backend.
Only the Python standard library is imported here deliberately: the idle path
must not import Headroom, ONNX Runtime, transformers or the MCP SDK.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import signal
import socket
import sys
import time
from pathlib import Path
def _int_env(name: str, default: int, minimum: int = 1) -> int:
try:
value = int(os.environ.get(name, str(default)))
except (TypeError, ValueError):
value = default
return max(minimum, value)
GATE_HOST = os.environ.get("HEADROOM_GATE_HOST", "127.0.0.1")
GATE_PORT = _int_env("HEADROOM_GATE_PORT", 8787)
BACKEND_HOST = os.environ.get("HEADROOM_BACKEND_HOST", "127.0.0.1")
BACKEND_PORT = _int_env("HEADROOM_BACKEND_PORT", 8789)
HEADROOM_BIN = os.environ.get("HEADROOM_REAL_BIN", "/usr/bin/headroom")
IDLE_TIMEOUT = _int_env("HEADROOM_IDLE_TIMEOUT_SECONDS", 900)
START_TIMEOUT = _int_env("HEADROOM_START_TIMEOUT_SECONDS", 60)
STOP_TIMEOUT = _int_env("HEADROOM_STOP_TIMEOUT_SECONDS", 10)
HF_HOME = os.environ.get("HF_HOME", str(Path.home() / ".headroom/hf"))
MAX_HEADER_BYTES = 128 * 1024
COPY_CHUNK = 64 * 1024
def log(message: str) -> None:
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{stamp}] headroom-gate: {message}", file=sys.stderr, flush=True)
class BackendUnavailable(RuntimeError):
pass
class ProxyGate:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._process: asyncio.subprocess.Process | None = None
self._last_activity = time.monotonic()
self._active_connections = 0
self._stopping = False
self._watch_task: asyncio.Task[None] | None = None
@property
def state(self) -> str:
process = self._process
if process is None:
return "dormant"
if process.returncode is None:
return "running"
return "stopped"
def touch(self) -> None:
self._last_activity = time.monotonic()
async def _backend_healthy(self) -> bool:
for path in ("/livez", "/health"):
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(BACKEND_HOST, BACKEND_PORT), timeout=1.0
)
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {BACKEND_HOST}:{BACKEND_PORT}\r\n"
"Connection: close\r\n\r\n"
).encode()
writer.write(request)
await writer.drain()
line = await asyncio.wait_for(reader.readline(), timeout=1.0)
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
if line.startswith(b"HTTP/") and b" 2" in line[:16]:
return True
except (OSError, asyncio.TimeoutError):
continue
return False
async def ensure_backend(self) -> None:
if await self._backend_healthy():
self.touch()
return
async with self._lock:
if await self._backend_healthy():
self.touch()
return
process = self._process
if process is not None and process.returncode is None:
await self._wait_until_healthy()
self.touch()
return
if not os.path.isfile(HEADROOM_BIN) or not os.access(HEADROOM_BIN, os.X_OK):
raise BackendUnavailable(f"Headroom executable is unavailable: {HEADROOM_BIN}")
Path(HF_HOME).mkdir(parents=True, exist_ok=True)
environment = os.environ.copy()
environment.update(
{
"HF_HOME": HF_HOME,
"HEADROOM_PROXY_GATE": "1",
}
)
command = [
HEADROOM_BIN,
"proxy",
"--host",
BACKEND_HOST,
"--port",
str(BACKEND_PORT),
"--code-aware",
]
log(f"starting backend on {BACKEND_HOST}:{BACKEND_PORT}")
try:
self._process = await asyncio.create_subprocess_exec(
*command,
env=environment,
start_new_session=True,
)
except Exception as exc:
self._process = None
raise BackendUnavailable(f"unable to start Headroom: {exc}") from exc
self._watch_task = asyncio.create_task(self._watch_backend(self._process))
try:
await self._wait_until_healthy()
except Exception:
await self._terminate_backend_locked("startup failure")
raise
self.touch()
async def _wait_until_healthy(self) -> None:
deadline = time.monotonic() + START_TIMEOUT
while time.monotonic() < deadline:
process = self._process
if process is not None and process.returncode is not None:
raise BackendUnavailable(
f"Headroom exited during startup with status {process.returncode}; "
"see the add-on log"
)
if await self._backend_healthy():
log("backend is ready")
return
await asyncio.sleep(0.25)
raise BackendUnavailable(
f"Headroom did not become ready within {START_TIMEOUT}s; see the add-on log"
)
async def _watch_backend(self, process: asyncio.subprocess.Process) -> None:
returncode = await process.wait()
async with self._lock:
was_current = self._process is process
if was_current:
self._process = None
if not self._stopping and was_current:
log(f"backend exited with status {returncode}")
async def stop_backend(self, reason: str) -> None:
async with self._lock:
await self._terminate_backend_locked(reason)
async def _terminate_backend_locked(self, reason: str) -> None:
process = self._process
if process is None:
return
if process.returncode is not None:
self._process = None
return
log(f"stopping backend ({reason})")
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(process.wait(), timeout=STOP_TIMEOUT)
except asyncio.TimeoutError:
log("backend did not stop after SIGTERM; sending SIGKILL")
with contextlib.suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
with contextlib.suppress(Exception):
await process.wait()
self._process = None
async def idle_monitor(self) -> None:
interval = max(5, min(30, IDLE_TIMEOUT // 4))
while not self._stopping:
await asyncio.sleep(interval)
process = self._process
idle_for = time.monotonic() - self._last_activity
if (
process is not None
and process.returncode is None
and idle_for >= IDLE_TIMEOUT
):
await self.stop_backend(f"idle for {int(idle_for)}s")
def status_payload(self) -> bytes:
process = self._process
payload = {
"status": self.state,
"backend_pid": process.pid if process is not None and process.returncode is None else None,
"active_connections": self._active_connections,
"idle_seconds": round(time.monotonic() - self._last_activity, 1),
"idle_timeout_seconds": IDLE_TIMEOUT,
"backend": f"{BACKEND_HOST}:{BACKEND_PORT}",
}
body = json.dumps(payload, separators=(",", ":")).encode()
return (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: application/json\r\n"
+ f"Content-Length: {len(body)}\r\n".encode()
+ b"Connection: close\r\n\r\n"
+ body
)
async def handle_client(
self,
client_reader: asyncio.StreamReader,
client_writer: asyncio.StreamWriter,
) -> None:
self._active_connections += 1
real_traffic = False
peer = client_writer.get_extra_info("peername")
try:
try:
initial = await asyncio.wait_for(
client_reader.readuntil(b"\r\n\r\n"), timeout=15.0
)
except (
asyncio.IncompleteReadError,
asyncio.LimitOverrunError,
asyncio.TimeoutError,
):
return
if len(initial) > MAX_HEADER_BYTES:
await self._send_error(client_writer, 431, "request headers too large")
return
first_line = initial.split(b"\r\n", 1)[0]
parts = first_line.split(b" ")
target = parts[1].split(b"?", 1)[0] if len(parts) >= 2 else b""
if target == b"/gate/status":
client_writer.write(self.status_payload())
await client_writer.drain()
return
real_traffic = True
self.touch()
try:
await self.ensure_backend()
backend_reader, backend_writer = await asyncio.wait_for(
asyncio.open_connection(BACKEND_HOST, BACKEND_PORT), timeout=5.0
)
except (BackendUnavailable, OSError, asyncio.TimeoutError) as exc:
log(f"backend unavailable for {peer}: {exc}")
await self._send_error(client_writer, 503, str(exc))
return
backend_writer.write(initial)
await backend_writer.drain()
self.touch()
upstream = asyncio.create_task(self._pipe(client_reader, backend_writer))
downstream = asyncio.create_task(self._pipe(backend_reader, client_writer))
done, pending = await asyncio.wait(
{upstream, downstream}, return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
for task in done | pending:
with contextlib.suppress(
asyncio.CancelledError,
ConnectionError,
OSError,
):
await task
backend_writer.close()
with contextlib.suppress(Exception):
await backend_writer.wait_closed()
finally:
self._active_connections = max(0, self._active_connections - 1)
if real_traffic:
self.touch()
client_writer.close()
with contextlib.suppress(Exception):
await client_writer.wait_closed()
async def _pipe(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
while True:
data = await reader.read(COPY_CHUNK)
if not data:
with contextlib.suppress(Exception):
writer.write_eof()
return
writer.write(data)
await writer.drain()
self.touch()
@staticmethod
async def _send_error(
writer: asyncio.StreamWriter,
status: int,
detail: str,
) -> None:
reason = (
"Service Unavailable"
if status == 503
else "Request Header Fields Too Large"
)
body = json.dumps({"error": detail}).encode()
response = (
f"HTTP/1.1 {status} {reason}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
).encode() + body
writer.write(response)
with contextlib.suppress(Exception):
await writer.drain()
async def shutdown(self) -> None:
self._stopping = True
await self.stop_backend("gate shutdown")
async def async_main() -> None:
gate = ProxyGate()
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop_event.set)
server = await asyncio.start_server(
gate.handle_client,
GATE_HOST,
GATE_PORT,
limit=MAX_HEADER_BYTES + 1,
family=socket.AF_INET,
)
addresses = ", ".join(str(sock.getsockname()) for sock in server.sockets or [])
log(
f"listening on {addresses}; backend is lazy and stops after {IDLE_TIMEOUT}s idle; "
"status endpoint: /gate/status"
)
monitor = asyncio.create_task(gate.idle_monitor())
async with server:
await stop_event.wait()
monitor.cancel()
with contextlib.suppress(asyncio.CancelledError):
await monitor
await gate.shutdown()
if __name__ == "__main__":
try:
asyncio.run(async_main())
except KeyboardInterrupt:
pass

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More