From 3354af026f990bb041a6ed0c9d6695ff1ddaab6b Mon Sep 17 00:00:00 2001 From: alexbelgium Date: Wed, 15 Jul 2026 20:55:27 +0200 Subject: [PATCH 1/3] fix(claude_desktop): make Headroom actually save tokens (cowork routing + Kompress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headroom kept reporting zero savings for two independent reasons: 1. Desktop cowork/local-agent-mode sessions never reached the proxy. Desktop spawns its bundled Claude Code binary at an absolute path (bypassing the PATH wrapper) with ANTHROPIC_BASE_URL pinned to the production endpoint (headroom #869). Manage env.ANTHROPIC_BASE_URL in ~/.claude/settings.json instead — Claude Code writes settings `env` entries over the inherited environment at startup, and cowork sessions load user settings. Managed-value semantics: only set or remove the variable when absent or equal to the add-on-managed proxy URL, so a user-customized endpoint is never clobbered. 2. Even proxied traffic compressed nothing (175 requests, 0 saved). The proxy's startup preload is cache-only, but the HF model cache defaulted to ~/.cache -> tmpfs, wiped every restart, so the Kompress ONNX model and its separately fetched ModernBERT tokenizer were never cached and the engine idled in "deferred" mode forever (misleadingly logged as "Kompress: not installed"). svc-headroom now sets HF_HOME to persistent ~/.headroom/hf and pre-warms the cache once at startup, bounded at 300s so an offline install still starts the proxy in pass-through mode and retries next boot. The proxy extra's ONNX runtime suffices — the multi-GB PyTorch [ml] extra is deliberately not installed. Verified live: proxy logs "Kompress: ENABLED (ModernBERT token compressor)" after restart, and a terminal `claude -p` round-trip increments the proxy's api_requests counter. Co-Authored-By: Claude Fable 5 --- claude_desktop/CHANGELOG.md | 5 ++ claude_desktop/Dockerfile | 5 +- claude_desktop/config.yaml | 2 +- .../rootfs/etc/cont-init.d/82-claude_tools.sh | 57 +++++++++++++++++++ .../etc/s6-overlay/s6-rc.d/svc-headroom/run | 21 +++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index b15957c674..970d50d0a4 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.27 (15-07-2026) + +- Route Claude Desktop cowork/local-agent-mode sessions through the Headroom proxy. Desktop spawns its bundled Claude Code binary at an absolute path (bypassing the add-on's PATH wrapper) with `ANTHROPIC_BASE_URL` pinned to the production endpoint, so those sessions never produced proxy savings. The add-on now manages `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json` — settings `env` entries replace inherited environment values at CLI startup — gated on `headroom_wrap_claude_code` and never overwriting a user-customized endpoint. +- Fix Headroom's Kompress compression engine never activating, which made even proxied traffic record zero token savings (e.g. 175 requests, 0 saved). The proxy's startup preload is deliberately cache-only, but the HuggingFace model cache defaulted to `~/.cache` — tmpfs in this add-on, wiped every restart — so the ONNX model (plus the separately fetched `answerdotai/ModernBERT-base` tokenizer) was never cached and the engine idled in "deferred" mode forever, misleadingly logged as `Kompress: not installed`. `svc-headroom` now points `HF_HOME` at persistent storage (`~/.headroom/hf`, ~270 MB) and pre-warms the cache once at startup (bounded at 300 s, so an offline install still starts the proxy in pass-through mode and retries next boot). The already-installed `proxy` extra's ONNX runtime is sufficient — the multi-gigabyte PyTorch `ml` extra is deliberately not installed. + ## 1.26 (15-07-2026) - Fix startup permission failures that prevented Claude Desktop from starting: storage was chowned to a hardcoded `1000:1000`, but the shared `abc` desktop user was never mapped to that UID. During init `abc` was still the image default (`911`), so TokenSave (`.claude.json.new`), RTK (`RTK.md`), nginx, PulseAudio, the Mesa shader cache, and Claude Desktop itself all hit `Permission denied`; the base image's `init-adduser` then remapped `abc` to root mid-startup (PUID/PGID were read from add-on options where they did not exist, falling back to `0`), which also made Claude Code reject `permission_mode: bypass`. diff --git a/claude_desktop/Dockerfile b/claude_desktop/Dockerfile index fc90eb52d2..8b8a936ab2 100644 --- a/claude_desktop/Dockerfile +++ b/claude_desktop/Dockerfile @@ -143,7 +143,10 @@ RUN /usr/local/bin/rtk --version && /usr/local/bin/tokensave --version # Install only the Headroom proxy, code-compression, and MCP features used by this add-on, # plus mcp-proxy (stdio->HTTP bridge for the Home Assistant MCP server) and uv (fast -# installer used for the additional_pip option). +# installer used for the additional_pip option). The `proxy` extra already ships the ONNX +# runtime + transformers needed by the Kompress compressor — the `ml` extra (full PyTorch, +# ~5 GB with CUDA wheels) is deliberately NOT installed; svc-headroom pre-warms the ONNX +# model into the persistent HF cache instead. RUN apt-get update && \ apt-get install -y --no-install-recommends nodejs && \ pip3 install --break-system-packages "headroom-ai[proxy,code,mcp]" mcp-proxy uv websockets && \ diff --git a/claude_desktop/config.yaml b/claude_desktop/config.yaml index 0cfdfb153d..6dfd48e420 100644 --- a/claude_desktop/config.yaml +++ b/claude_desktop/config.yaml @@ -109,5 +109,5 @@ slug: claude_desktop tmpfs: true udev: true url: https://github.com/alexbelgium/hassio-addons -version: "1.26" +version: "1.27" video: true diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index be6a8bf53d..ff8da41be1 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -308,6 +308,63 @@ if new != text: PY fi +# Route every Claude Code session through the Headroom proxy via the `env` block in the user's +# ~/.claude/settings.json. Claude Code writes settings `env` entries into the process +# environment at startup, replacing inherited values — this is the only supported way to reach +# Desktop cowork/local-agent-mode sessions, which spawn the bundled CLI at an absolute path +# (bypassing the PATH wrapper) with ANTHROPIC_BASE_URL pinned to the production endpoint +# (headroom #869). Managed-value semantics: only set or remove the variable when it is absent +# or already equals the add-on-managed proxy URL, so a user-customized endpoint is never +# clobbered. The svc-headroom longrun is s6-supervised, so a crashed proxy restarts within +# seconds; the terminal wrapper's per-launch health check remains as an extra safety net. +if $HEADROOM_ENABLED && bashio::config.true 'headroom_wrap_claude_code'; then + HEADROOM_ROUTE_ACTION="add" +else + HEADROOM_ROUTE_ACTION="remove" +fi +HEADROOM_ROUTE_ACTION="$HEADROOM_ROUTE_ACTION" python3 - <<'PY' || bashio::log.warning "Unable to manage the Claude Code proxy routing env" +import json +import os +from pathlib import Path + +MANAGED_URL = "http://127.0.0.1:8787" + +path = Path.home() / ".claude" / "settings.json" +try: + data = json.loads(path.read_text()) if path.exists() else {} + if not isinstance(data, dict): + data = {} +except Exception: + if path.exists(): + path.rename(path.with_suffix(path.suffix + ".bak")) + data = {} + +env = data.get("env") +if not isinstance(env, dict): + env = {} +current = env.get("ANTHROPIC_BASE_URL") +changed = False + +if os.environ["HEADROOM_ROUTE_ACTION"] == "add": + if current is None or current == MANAGED_URL: + if current != MANAGED_URL: + env["ANTHROPIC_BASE_URL"] = MANAGED_URL + changed = True + else: + print(f"Claude settings env already sets ANTHROPIC_BASE_URL={current}; leaving it untouched") +elif current == MANAGED_URL: + del env["ANTHROPIC_BASE_URL"] + changed = True + +if changed: + if env: + data["env"] = env + elif "env" in data: + del data["env"] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") +PY + # Tell Claude Code that it can configure Home Assistant over the Core API via the shipped # `ha-cli` helper (no /config filesystem mount needed). Managed, idempotent block appended to # the user's global CLAUDE.md; removed when the helper is disabled. Mirrors the headroom block. diff --git a/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run b/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run index a1ee38d6b8..2119c40834 100755 --- a/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run +++ b/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run @@ -10,6 +10,27 @@ if bashio::config.true 'expose_headroom_dashboard'; then 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 runs forever in "deferred" mode + # and records zero compression savings. Cache on persistent storage and pre-warm it + # once (bounded so an offline install still starts the proxy, in pass-through mode, + # and retries the download on the next start). + export HF_HOME="${HOME}/.headroom/hf" + mkdir -p "$HF_HOME" + chown abc:abc "$HF_HOME" 2> /dev/null || true + # headroom lives in the lsiopy virtualenv; plain `python3` does not see its packages, + # so derive the venv interpreter from the resolved headroom entry script. + headroom_python="$(dirname "$(readlink -f "$(command -v headroom)")")/python3" + if ! s6-setuidgid abc "$headroom_python" -c \ + 'from headroom.transforms.kompress_compressor import _load_kompress; _load_kompress(allow_download=False)' \ + > /dev/null 2>&1; then + bashio::log.info "svc-headroom: pre-warming the Kompress model cache (one-time download)" + timeout 300 s6-setuidgid abc "$headroom_python" -c \ + 'from headroom.transforms.kompress_compressor import _load_kompress; _load_kompress()' \ + || bashio::log.warning "svc-headroom: Kompress pre-warm failed; compression stays deferred until the model can be downloaded" + fi 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 From b8c7cc3815f2a5a6c57d87b63df095335f995ac8 Mon Sep 17 00:00:00 2001 From: alexbelgium Date: Wed, 15 Jul 2026 22:19:01 +0200 Subject: [PATCH 2/3] fix(claude_desktop): address CodeRabbit findings on TokenSave startup hardening flock -n silently skipped TokenSave prep on lock contention with no retry until next restart; wait up to 60s instead (kernel releases flock the instant its owner exits, so only a truly stuck lock can't clear within that window). Quarantine fired on any sync failure after 3 retries, including transient causes (permissions, disk full, missing binary) unrelated to corruption. Now only quarantines when stderr names actual database corruption (SQLite malformed/not-a-database/disk-image wording); other failures leave the index untouched and retry next start. Co-Authored-By: Claude Fable 5 --- .../rootfs/etc/cont-init.d/82-claude_tools.sh | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index ff8da41be1..8f01b01db2 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -203,12 +203,17 @@ if $TOKENSAVE_ENABLED; then # Prepare the per-repo semantic graph defensively so a hard add-on stop or storage # hiccup can never leave a broken index that fails every subsequent boot: # * a startup-scoped flock serializes against an overlapping restart (and any git - # post-commit/checkout sync hook that fires mid-boot), so two writers never race; + # post-commit/checkout sync hook that fires mid-boot); waits up to 60s for the + # other writer to finish rather than silently skipping, since a held lock clears + # itself the moment its holder exits or dies (the kernel releases flock on exit); # * an existing index is refreshed with a cheap incremental `sync`, retried a few # times because SQLITE_BUSY under lock contention is transient, not corruption; - # * only a genuinely unreadable/malformed index (sync still failing after retries) - # or a half-written one from an interrupted `init` is quarantined and rebuilt, - # so the graph self-heals instead of propagating corruption; + # * quarantine is reserved for sync failures whose stderr actually names database + # corruption (SQLite's own "malformed"/"not a database"/"disk image" wording) or + # a half-written index from an interrupted `init` (sentinel-flagged). Any other + # failure (permissions, disk full, missing binary, ...) leaves the existing index + # untouched and simply retries on the next start — corruption should self-heal, + # a transient environment problem should not nuke a healthy graph; # * `init` is bracketed by a sentinel file so an interrupted full build is detected # as incomplete on the next start and rebuilt rather than trusted. # All file operations run as the abc runtime user because the repo `.tokensave` @@ -223,10 +228,13 @@ if $TOKENSAVE_ENABLED; then initflag="$ts_dir/.init-incomplete" mkdir -p "$ts_dir" exec 9>"$lock" - if ! flock -n 9; then - echo "TokenSave: index busy for $repo_root; skipping startup sync" >&2 + if ! flock -w 60 9; then + echo "TokenSave: index still locked for $repo_root after 60s; skipping startup sync" >&2 exit 0 fi + is_corruption() { + printf "%s" "$1" | grep -qiE "malformed|not a database|file is encrypted|disk image|database.*corrupt" + } quarantine() { stamp="$(date +%Y%m%d-%H%M%S)" bdir="$ts_dir/corrupt-$stamp" @@ -239,14 +247,20 @@ if $TOKENSAVE_ENABLED; then if [ -f "$db" ] && [ ! -f "$initflag" ]; then attempt=1 while :; do - tokensave sync "$repo_root" && exit 0 + sync_err="$(tokensave sync "$repo_root" 2>&1 1>/dev/null)" && exit 0 [ "$attempt" -ge 3 ] && break echo "TokenSave: sync attempt $attempt failed for $repo_root; retrying" >&2 attempt=$((attempt + 1)) sleep 2 done - echo "TokenSave: sync failed after retries for $repo_root; rebuilding index" >&2 - quarantine + if is_corruption "$sync_err"; then + echo "TokenSave: sync failed after retries for $repo_root (corruption detected); rebuilding index" >&2 + quarantine + else + echo "TokenSave: sync failed after retries for $repo_root (no corruption signature); leaving index in place, will retry next start" >&2 + echo "TokenSave: last sync error: $sync_err" >&2 + exit 1 + fi elif [ -f "$db" ]; then echo "TokenSave: previous init did not finish for $repo_root; rebuilding index" >&2 quarantine From b3c27024d83854bffb494aeaa8736cf3ae69f8f9 Mon Sep 17 00:00:00 2001 From: alexbelgium Date: Thu, 16 Jul 2026 08:20:50 +0200 Subject: [PATCH 3/3] fix(claude_desktop): remove blocking Kompress pre-warm, use proxy's own background loader Codex flagged that the synchronous pre-warm (up to 300s) blocked the proxy port bind, defeating the terminal wrapper's health-check fallback and, combined with the new settings-managed ANTHROPIC_BASE_URL, could send terminal Claude Code launches to a proxy that was not listening yet. The proxy already has a non-blocking answer to a cold cache: content_router.py calls compressor.ensure_background_load() on first use and passes the request through uncompressed until the model lands, so the port always binds immediately. Persisting HF_HOME alone is enough -- Kompress self-heals within the first couple of requests on a cold boot and loads instantly (eager preload) on every boot after. Co-Authored-By: Claude Fable 5 --- claude_desktop/CHANGELOG.md | 2 +- .../etc/s6-overlay/s6-rc.d/svc-headroom/run | 24 +++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index 970d50d0a4..d24ce18659 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -1,7 +1,7 @@ ## 1.27 (15-07-2026) - Route Claude Desktop cowork/local-agent-mode sessions through the Headroom proxy. Desktop spawns its bundled Claude Code binary at an absolute path (bypassing the add-on's PATH wrapper) with `ANTHROPIC_BASE_URL` pinned to the production endpoint, so those sessions never produced proxy savings. The add-on now manages `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json` — settings `env` entries replace inherited environment values at CLI startup — gated on `headroom_wrap_claude_code` and never overwriting a user-customized endpoint. -- Fix Headroom's Kompress compression engine never activating, which made even proxied traffic record zero token savings (e.g. 175 requests, 0 saved). The proxy's startup preload is deliberately cache-only, but the HuggingFace model cache defaulted to `~/.cache` — tmpfs in this add-on, wiped every restart — so the ONNX model (plus the separately fetched `answerdotai/ModernBERT-base` tokenizer) was never cached and the engine idled in "deferred" mode forever, misleadingly logged as `Kompress: not installed`. `svc-headroom` now points `HF_HOME` at persistent storage (`~/.headroom/hf`, ~270 MB) and pre-warms the cache once at startup (bounded at 300 s, so an offline install still starts the proxy in pass-through mode and retries next boot). The already-installed `proxy` extra's ONNX runtime is sufficient — the multi-gigabyte PyTorch `ml` extra is deliberately not installed. +- Fix Headroom's Kompress compression engine never activating, which made even proxied traffic record zero token savings (e.g. 175 requests, 0 saved). The proxy's startup preload is deliberately cache-only, but the HuggingFace model cache defaulted to `~/.cache` — tmpfs in this add-on, wiped every restart — so the ONNX model (plus the separately fetched `answerdotai/ModernBERT-base` tokenizer) was never cached and the engine idled in "deferred" mode forever, misleadingly logged as `Kompress: not installed`. `svc-headroom` now points `HF_HOME` at persistent storage (`~/.headroom/hf`, ~270 MB); the proxy's own request path already downloads a missing model in the background on first use and passes requests through uncompressed until it lands, so no blocking startup pre-warm is needed — the port binds immediately either way, and Kompress activates within the first couple of requests on the first boot, then loads instantly on every boot after. The already-installed `proxy` extra's ONNX runtime is sufficient — the multi-gigabyte PyTorch `ml` extra is deliberately not installed. ## 1.26 (15-07-2026) diff --git a/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run b/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run index 2119c40834..f1dcbcbd98 100755 --- a/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run +++ b/claude_desktop/rootfs/etc/s6-overlay/s6-rc.d/svc-headroom/run @@ -13,24 +13,18 @@ if bashio::config.true 'install_headroom' && command -v headroom > /dev/null 2>& # 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 runs forever in "deferred" mode - # and records zero compression savings. Cache on persistent storage and pre-warm it - # once (bounded so an offline install still starts the proxy, in pass-through mode, - # and retries the download on the next start). + # 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 - # headroom lives in the lsiopy virtualenv; plain `python3` does not see its packages, - # so derive the venv interpreter from the resolved headroom entry script. - headroom_python="$(dirname "$(readlink -f "$(command -v headroom)")")/python3" - if ! s6-setuidgid abc "$headroom_python" -c \ - 'from headroom.transforms.kompress_compressor import _load_kompress; _load_kompress(allow_download=False)' \ - > /dev/null 2>&1; then - bashio::log.info "svc-headroom: pre-warming the Kompress model cache (one-time download)" - timeout 300 s6-setuidgid abc "$headroom_python" -c \ - 'from headroom.transforms.kompress_compressor import _load_kompress; _load_kompress()' \ - || bashio::log.warning "svc-headroom: Kompress pre-warm failed; compression stays deferred until the model can be downloaded" - fi 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