mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-15 17:42:29 +02:00
Merge pull request #2869 from alexbelgium/fix/headroom-cowork-routing-ml
fix(claude_desktop): Headroom zero savings — cowork session routing + Kompress activation
This commit is contained in:
@@ -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); 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)
|
||||
|
||||
- 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`.
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -308,6 +322,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.
|
||||
|
||||
@@ -10,6 +10,21 @@ 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 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
|
||||
|
||||
Reference in New Issue
Block a user