Compare commits

...

19 Commits

Author SHA1 Message Date
Alexandre
3d2f3aa193 Merge pull request #2871 from alexbelgium/feat/headroom-posttooluse-hook
feat(claude_desktop): auto-compress large tool outputs via Headroom PostToolUse hook
2026-07-16 14:53:16 +02:00
alexbelgium
26101a1104 fix(claude_desktop): guard env parsing and truncate file-list arrays in headroom hook
Two review findings on PR #2871:

- coderabbitai: MIN_CHARS/MIN_SAVED_TOKENS parsed with a bare int() at module
  import time, before any try/except could catch a bad value — a malformed
  env_vars passthrough would crash the hook on every matched tool call instead
  of failing open as documented. Wrapped in _int_env() with a safe fallback.

- chatgpt-codex-connector: Glob and Grep (files_with_matches mode) return a
  `filenames: string[]` field per the CLI's own output schema, which the
  hook's string-only candidate scan never touched — large file listings, the
  exact case named in the CLAUDE.md guidance this add-on installs, passed
  through uncompressed. Verified empirically that routing such an array
  through compress()/SmartCrusher (as done for JSON-blob string fields)
  silently subsamples it — 600 paths collapsed to ~15 with no visible marker,
  unsafe for paths the model needs to act on individually. Added a separate
  deterministic path: arrays over ARRAY_KEEP (40) entries are truncated in
  order with one labeled marker entry appended, full array recoverable from
  the CCR store by hash. Verified round-trip on Glob- and Grep-shaped
  payloads (600 and 200 entries); confirmed order preservation and that
  small arrays still pass through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:46:10 +02:00
github-actions
a4764364da GitHub bot: changelog [nobuild] 2026-07-16 12:37:00 +00:00
alexbelgium
9bbd72e70a feat(claude_desktop): auto-compress large tool outputs via Headroom PostToolUse hook
Desktop-spawned Claude Code sessions (cowork/dispatch) pin ANTHROPIC_BASE_URL
to the production endpoint (headroom #869), so the transparent proxy never
sees their traffic and compression depended on the model voluntarily calling
the headroom MCP tools. A managed PostToolUse hook now compresses
Bash/Grep/Glob/WebFetch outputs over ~4000 chars in every session type with
Headroom's rule-based pipeline, swapping them in via
hookSpecificOutput.updatedToolOutput with a retrieval marker. Originals live
in the shared CCR SQLite store, so mcp__headroom__headroom_retrieve recovers
them; savings land in the durable ledger (client "posttooluse-hook").

The hook fails open, never compresses stderr, skips sub-50-token savings, and
registers idempotently in ~/.claude/settings.json only after a --self-test
gate; new headroom_auto_compress option (default true) removes the managed
entry cleanly when disabled. Measured: 10781->2964 tokens (73%) on a
representative HA states dump, ~1.7 s hook overhead, <100 ms pass-through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:31:11 +02:00
Alexandre
e345752d00 Update config.yaml 2026-07-16 14:30:21 +02:00
Alexandre
7e2c8eda5e Fix generated package-lock conflicts when merging review PRs 2026-07-16 14:28:31 +02:00
Alexandre
65fbc7dae2 Merge pull request #2870 from alexbelgium/fix/headroom-hf-home-and-bashrc-home-dedup
fix(claude_desktop): repair Headroom MCP model cache, HOME dedup, gitconfig ownership
2026-07-16 13:49:35 +02:00
Alexandre
95641d253c Update 83-github_cli.sh 2026-07-16 13:49:02 +02:00
alexbelgium
bdc6231aa3 fix(claude_desktop): repair Headroom MCP model cache, HOME dedup, gitconfig owner
Three add-on runtime-environment bugs, all found while investigating a Headroom
dashboard stuck at 0 gain.

Headroom MCP server had no HF_HOME. 1.27 fixed the Kompress model cache for the
svc-headroom proxy longrun by exporting HF_HOME there, but the MCP server is a
different process: Claude Desktop and Claude Code spawn it from the registered
mcpServers entry, so it never saw that export and kept resolving the HuggingFace
cache to ~/.cache, which this add-on symlinks to tmpfs. Its Kompress ML path
therefore never found the model, re-downloaded ~270 MB into tmpfs on every boot,
and lost it on the next one -- headroom_compress returned router:noop (output
unchanged) for prose and other unstructured content. Rule-based compression
(SmartCrusher, structured tool output) was unaffected and worked throughout,
which is why the failure only showed on some payloads. Carry env.HF_HOME on the
managed headroom entry in both claude_desktop_config.json and ~/.claude.json.

~/.gitconfig was written as root and left unreadable by abc. `git config --global`
ran as root during init and rewrites the file on every start, so 20-folders.sh's
earlier recursive chown never stuck to it; .config/gh survived abc-owned only
because the "already authenticated" branch skips rewriting it. The user that
actually runs git, gh and Claude could not read its own committer identity or the
gh credential helper: every commit failed with "Author identity unknown" and
authenticated pushes fell back to prompting. Run the git/gh setup as abc via
s6-setuidgid, matching 81-tokensave_repositories.sh, and reclaim root-owned
copies left by earlier versions before writing.

~/.bashrc accumulated stale HOME/FM_HOME exports across data_location changes.
The idempotency guard only tested for the current $LOCATION, so changing the
option and later changing it back appended a second block while leaving the first,
and the last one written won for every interactive shell. $HOME then pointed at a
directory the add-on no longer manages, so anything resolving config through it
read the wrong path -- `headroom doctor` reported "claude: not routed (no
~/.claude/settings.json)" against a correctly routed install, and bare `headroom`
invocations created a stray .headroom tree under the old location. Make the block
marker-delimited and rewrite it from scratch each boot.

Verified on a running add-on: headroom_compress now reports 1909 -> 1122 tokens
(41.2%, router:mixed) through the live MCP server; `headroom doctor` reports
"claude: routed via /data/data/.claude/settings.json"; and git commits work as abc
without a repo-local identity override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:36:05 +02:00
github-actions
ae2c29b977 GitHub bot: changelog [nobuild] 2026-07-16 06:54:55 +00:00
Alexandre
3114a6cc94 Update config.yaml 2026-07-16 08:41:05 +02:00
Alexandre
297102e908 Update Dockerfile 2026-07-16 08:40:49 +02:00
github-actions
39efd5602f GitHub bot: changelog [nobuild] 2026-07-16 06:26:26 +00:00
Alexandre
8b3db6e325 Merge pull request #2869 from alexbelgium/fix/headroom-cowork-routing-ml
fix(claude_desktop): Headroom zero savings — cowork session routing + Kompress activation
2026-07-16 08:26:14 +02:00
Alexandre
e70b8db0fa Update config.yaml 2026-07-16 08:24:28 +02:00
Alexandre
4872bd89c9 Remove healthcheck from Dockerfile 2026-07-16 08:24:18 +02:00
alexbelgium
b3c27024d8 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 <noreply@anthropic.com>
2026-07-16 08:20:56 +02:00
alexbelgium
b8c7cc3815 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 <noreply@anthropic.com>
2026-07-15 22:19:14 +02:00
alexbelgium
3354af026f fix(claude_desktop): make Headroom actually save tokens (cowork routing + Kompress)
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 <noreply@anthropic.com>
2026-07-15 20:55:27 +02:00
15 changed files with 526 additions and 40 deletions

View File

@@ -1,3 +1,5 @@
## source-20260716 (16-07-2026)
- Minor bugs fixed
## source-20260714 (14-07-2026)
- Minor bugs fixed
## source-20260709 (09-07-2026)

View File

@@ -127,5 +127,5 @@ slug: birdnet-go-dev
udev: true
url: https://github.com/alexbelgium/hassio-addons
usb: true
version: "source-20260714"
version: "source-20260716"
video: true

View File

@@ -77,10 +77,27 @@ for entry in "${prs[@]}"; do
# Fetch the PR head commit by number; works unauthenticated for public repos.
git fetch --no-tags origin "refs/pull/${number}/head"
if ! git merge --no-edit --no-ff -m "Merge PR #${number}: ${title}" "${sha}"; then
echo "!!! Merge conflict while merging PR #${number} (${title})." >&2
echo "!!! Resolve the conflict in the fork or pause this PR, then rebuild." >&2
git merge --abort || true
exit 1
mapfile -t conflicted_files < <(git diff --name-only --diff-filter=U)
# package-lock.json is generated content and stacked PRs can carry an
# older copy even when their source changes merge cleanly. Keep the
# lockfile already assembled from upstream and earlier PRs, but only
# when it is the sole conflict. Any source conflict remains fatal.
if [ "${#conflicted_files[@]}" -eq 1 ] \
&& [ "${conflicted_files[0]}" = "frontend/package-lock.json" ]; then
log "Resolving generated frontend/package-lock.json conflict using the accumulated tree"
git checkout --ours -- frontend/package-lock.json
git add frontend/package-lock.json
git commit --no-edit
else
echo "!!! Merge conflict while merging PR #${number} (${title})." >&2
if [ "${#conflicted_files[@]}" -gt 0 ]; then
printf '!!! Conflicting file: %s\n' "${conflicted_files[@]}" >&2
fi
echo "!!! Resolve the conflict in the fork or pause this PR, then rebuild." >&2
git merge --abort || true
exit 1
fi
fi
done

View File

@@ -1,3 +1,18 @@
## 1.30 (16-07-2026)
- Compress large tool outputs automatically in every Claude Code session with a managed `PostToolUse` hook (new `headroom_auto_compress` option, enabled by default). Desktop-spawned sessions (cowork/dispatch) pin `ANTHROPIC_BASE_URL` to the production endpoint (headroom #869), so the transparent proxy never sees their traffic and compression there depended entirely on the model remembering to call the `headroom` MCP tools per the CLAUDE.md guidance — in practice most large outputs went uncompressed. The new `/usr/local/bin/headroom-posttooluse-compress.py` hook fires on `Bash`/`Grep`/`Glob`/`WebFetch` results over ~4000 characters, compresses them with Headroom's rule-based pipeline (SmartCrusher and friends; the Kompress ML path is disabled because its background model load can never complete inside a short-lived hook process), and swaps the result in via `hookSpecificOutput.updatedToolOutput` with a retrieval marker appended. Originals are stored in the shared CCR SQLite store (`~/.headroom/ccr_store.db` — the same one the headroom MCP server reads), so `mcp__headroom__headroom_retrieve` always recovers the full output; savings are recorded to the durable ledger (client `posttooluse-hook`) and show up in the existing gains report. The hook fails open (any error leaves the tool output untouched), never touches `stderr` fields so error text reaches the model verbatim, skips anything below a 50-token savings floor, and is registered idempotently in `~/.claude/settings.json` only after a `--self-test` confirms the interpreter can import headroom; disabling the option (or Headroom) removes the managed entry without touching user-defined hooks. Measured on a representative Home Assistant `states` dump: 10781 -> 2964 tokens (73% saved) at ~1.7 s hook overhead, with sub-100 ms pass-through for small outputs.
## 1.29 (16-07-2026)
- Point the Headroom MCP server at the persistent Kompress model cache. 1.27 set `HF_HOME` on the `svc-headroom` proxy longrun only, but the MCP server is a separate process spawned by Claude Desktop / Claude Code from the registered `mcpServers` entry, so it never inherited that export and kept resolving the HuggingFace cache to `~/.cache` — symlinked to tmpfs here and wiped on every restart. Its Kompress ML path therefore never found the model, re-downloaded ~270 MB into tmpfs on each boot, and lost it again on the next one; `headroom_compress` fell back to `router:noop` (unchanged output) on prose and other unstructured content. The managed `headroom` entry in both `claude_desktop_config.json` and `~/.claude.json` now carries `env.HF_HOME` pointing at the same `~/.headroom/hf` cache the proxy warms. Rule-based compression (SmartCrusher, structured tool output) was unaffected and worked throughout.
- Fix `~/.gitconfig` being written as `root` and left unreadable by the `abc` runtime user, which broke git for the user that actually runs it: every commit failed with `Author identity unknown` and the `gh` credential helper was invisible to authenticated pushes. `git config --global` ran as root during init and rewrites the file on every start, so `20-folders.sh`'s earlier recursive chown never stuck to it (`.config/gh` survived abc-owned only because the "already authenticated" branch skips rewriting it). The git/gh setup now runs as `abc` via `s6-setuidgid`, matching `81-tokensave_repositories.sh`, and reclaims any root-owned copies left by an earlier version before writing.
- Fix `~/.bashrc` accumulating stale `HOME`/`FM_HOME` exports when `data_location` changes. The idempotency guard only tested for the *current* `$LOCATION`, so changing the option and later changing it back appended a second block while leaving the first, and the last one written won for every interactive shell — leaving `$HOME` pointing at a directory the add-on no longer manages. Any tool that resolves config through `$HOME` then read the wrong path (`headroom doctor` reported `claude: not routed (no ~/.claude/settings.json)` against a correctly routed install, and bare `headroom` invocations created a stray `.headroom` tree under the old location). The block is now marker-delimited and rewritten from scratch on every boot, so it is idempotent across any number of `data_location` changes.
## 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`.

View File

@@ -108,27 +108,22 @@ RUN install -d -m 0755 /etc/apt/keyrings && \
rm -rf /var/lib/apt/lists/*
# Install the current upstream hadolint and actionlint releases for both supported
# architectures. The GitHub release API resolves the latest asset at build time, so these
# developer tools are intentionally not version-pinned.
ARG HADOLINT_VERSION=v2.14.0
ARG ACTIONLINT_VERSION=v1.7.12
RUN set -eux; \
case "${BUILD_ARCH}" in \
amd64) hadolint_arch="x86_64"; actionlint_arch="amd64" ;; \
aarch64) hadolint_arch="arm64"; actionlint_arch="arm64" ;; \
*) echo "Unsupported validation-tools architecture: ${BUILD_ARCH}" >&2; exit 1 ;; \
esac; \
hadolint_name="hadolint-linux-${hadolint_arch}"; \
hadolint_url="$(curl -fsSL https://api.github.com/repos/hadolint/hadolint/releases/latest \
| jq -r --arg name "${hadolint_name}" '.assets[] | select(.name == $name) | .browser_download_url' \
| head -n 1)"; \
test -n "${hadolint_url}"; \
curl -fsSL --retry 3 --retry-delay 2 -o /usr/local/bin/hadolint "${hadolint_url}"; \
curl -fsSL --retry 3 --retry-delay 2 \
-o /usr/local/bin/hadolint \
"https://github.com/hadolint/hadolint/releases/download/${HADOLINT_VERSION}/hadolint-linux-${hadolint_arch}"; \
chmod 0755 /usr/local/bin/hadolint; \
actionlint_suffix="_linux_${actionlint_arch}.tar.gz"; \
actionlint_url="$(curl -fsSL https://api.github.com/repos/rhysd/actionlint/releases/latest \
| jq -r --arg suffix "${actionlint_suffix}" '.assets[] | select(.name | endswith($suffix)) | .browser_download_url' \
| head -n 1)"; \
test -n "${actionlint_url}"; \
curl -fsSL --retry 3 --retry-delay 2 -o /tmp/actionlint.tar.gz "${actionlint_url}"; \
curl -fsSL --retry 3 --retry-delay 2 \
-o /tmp/actionlint.tar.gz \
"https://github.com/rhysd/actionlint/releases/download/${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION#v}_linux_${actionlint_arch}.tar.gz"; \
tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint; \
install -m 0755 /tmp/actionlint /usr/local/bin/actionlint; \
rm -f /tmp/actionlint /tmp/actionlint.tar.gz; \
@@ -143,7 +138,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 && \

View File

@@ -99,6 +99,7 @@ Git synchronization hooks. A repository is indexed only when it is listed in
| `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. |
| `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. |
| `headroom_wrap_claude_code` | `true` | Route PATH-based Claude Code launches through the already-running Headroom proxy. |
| `headroom_auto_compress` | `true` | Auto-compress large tool outputs in every Claude Code session via a managed `PostToolUse` hook. |
| `expose_headroom_dashboard` | `false` | Bind Headroom to all interfaces. Port `8787/tcp` must also be mapped manually. |
| `install_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. |
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
@@ -175,6 +176,16 @@ the MCP integration. The `/usr/local/bin/claude` wrapper routes PATH-based Claud
Code sessions through `headroom wrap claude --no-proxy`, reusing the supervised
backend without starting a second proxy.
With `headroom_auto_compress` enabled (the default), a managed Claude Code
`PostToolUse` hook additionally compresses large `Bash`/`Grep`/`Glob`/`WebFetch`
outputs (over ~4000 characters) in **every** session type — terminal, Desktop
cowork, dispatch, and cron — without the model having to remember to call the
MCP tools. The original output is kept in Headroom's local store for one hour
and can always be recovered with `mcp__headroom__headroom_retrieve` using the
hash printed in the compression marker. Error text (`stderr`) is never
compressed, and plain prose passes through unchanged; the savings come from
structured output such as JSON dumps, search results, and logs.
The dashboard is disabled externally by default. To expose it:
1. Set `expose_headroom_dashboard: true`.

View File

@@ -49,6 +49,7 @@ options:
github_username: ""
enable_tools_health_report: true
expose_headroom_dashboard: false
headroom_auto_compress: true
headroom_wrap_claude_code: true
install_caveman: false
install_github_cli: true
@@ -96,6 +97,7 @@ schema:
github_username: str?
enable_tools_health_report: bool
expose_headroom_dashboard: bool
headroom_auto_compress: bool?
headroom_wrap_claude_code: bool
install_caveman: bool
install_github_cli: bool
@@ -109,5 +111,5 @@ slug: claude_desktop
tmpfs: true
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "1.26"
version: "1.30"
video: true

View File

@@ -85,10 +85,23 @@ printf "%s" "$LOCATION" > "$S6_ENVDIR/HOME"
printf "%s" "$LOCATION" > "$S6_ENVDIR/FM_HOME"
printf "%s" "/tmp/cache" > "$S6_ENVDIR/XDG_CACHE_HOME"
printf "%s" "$XDG_RUNTIME_DIR" > "$S6_ENVDIR/XDG_RUNTIME_DIR"
grep -qxF "export HOME=\"$LOCATION\"" ~/.bashrc 2>/dev/null || {
# Re-derived on every boot rather than gated on a "does it already say $LOCATION" grep: that
# guard only ever recognized the CURRENT $LOCATION, so a user who changed data_location and
# later changed it back left two stale HOME/FM_HOME exports in ~/.bashrc, with the last one
# (not necessarily the correct one) winning for every interactive shell. The marker makes this
# idempotent regardless of how many times $LOCATION has changed: strip any previously managed
# block, then append one that reflects the current value.
BASHRC_HOME_BEGIN="# --- BEGIN ADDON HOME (managed) ---"
BASHRC_HOME_END="# --- END ADDON HOME (managed) ---"
if [ -f ~/.bashrc ]; then
sed -i "/^${BASHRC_HOME_BEGIN}\$/,/^${BASHRC_HOME_END}\$/d" ~/.bashrc
fi
{
printf "%s\n" "$BASHRC_HOME_BEGIN"
printf "%s\n" "export HOME=\"$LOCATION\""
printf "%s\n" "export FM_HOME=\"$LOCATION\""
printf "%s\n" "export XDG_CACHE_HOME=\"/tmp/cache\""
printf "%s\n" "$BASHRC_HOME_END"
} >> ~/.bashrc
bashio::log.info "Creating $LOCATION"

View File

@@ -76,6 +76,7 @@ if bashio::config.true 'enable_ha_mcp'; then
fi
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)" \
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)" \
@@ -96,6 +97,11 @@ if os.environ["HEADROOM_ENABLED"] == "true":
desired["headroom"] = {
"command": os.environ["HEADROOM_BIN"],
"args": ["mcp", "serve", "--proxy-url", "http://127.0.0.1:8787"],
# The MCP server is a separate process from the svc-headroom proxy longrun and does
# not inherit its HF_HOME export, so Kompress falls back to the default (tmpfs, wiped
# every restart) cache dir, never finds the model, and silently no-ops every
# compression request. Point it at the same persistent cache the proxy warms.
"env": {"HF_HOME": os.environ["HEADROOM_HF_HOME"]},
}
if os.environ["TOKENSAVE_ENABLED"] == "true":
desired["tokensave"] = {"command": os.environ["TOKENSAVE_BIN"], "args": ["serve"]}
@@ -203,12 +209,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 +234,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 +253,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 +328,149 @@ 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
# Compress large tool outputs automatically in every Claude Code session via a managed
# PostToolUse hook (settings.json hooks apply to terminal, cowork, dispatch and cron sessions
# alike). Desktop-spawned sessions pin ANTHROPIC_BASE_URL to the production endpoint
# (headroom #869) so the proxy never sees their traffic, and the CLAUDE.md guidance above only
# helps when the model remembers to call the MCP tools. The hook closes that gap: outputs over
# ~4000 chars from Bash/Grep/Glob/WebFetch are compressed with Headroom's rule-based pipeline
# and swapped in through hookSpecificOutput.updatedToolOutput, with the original kept in the
# shared CCR store so the model can fetch it back with mcp__headroom__headroom_retrieve. The
# script fails open (any error leaves the tool output untouched) and its --self-test gate
# keeps a broken interpreter path from registering a hook that would warn on every tool call.
HEADROOM_HOOK_CMD="/usr/local/bin/headroom-posttooluse-compress.py"
HEADROOM_HOOK_ACTION="remove"
if $HEADROOM_ENABLED && bashio::config.true 'headroom_auto_compress'; then
if run_as_runtime_user "$HEADROOM_HOOK_CMD" --self-test; then
HEADROOM_HOOK_ACTION="add"
bashio::log.info "Registering the Headroom PostToolUse auto-compression hook"
else
bashio::log.warning "headroom-posttooluse-compress.py --self-test failed; not registering the auto-compression hook"
fi
fi
HEADROOM_HOOK_ACTION="$HEADROOM_HOOK_ACTION" HEADROOM_HOOK_CMD="$HEADROOM_HOOK_CMD" \
HEADROOM_HOOK_MATCHER="Bash|Grep|Glob|WebFetch" \
python3 - <<'PY' || bashio::log.warning "Unable to manage the Headroom auto-compression hook"
import json
import os
from pathlib import Path
action = os.environ["HEADROOM_HOOK_ACTION"]
command = os.environ["HEADROOM_HOOK_CMD"]
matcher = os.environ["HEADROOM_HOOK_MATCHER"]
path = Path.home() / ".claude" / "settings.json"
original = path.read_text() if path.exists() else None
try:
data = json.loads(original) if original is not None else {}
if not isinstance(data, dict):
data = {}
except Exception:
if action != "add":
raise SystemExit(0)
path.rename(path.with_suffix(path.suffix + ".bak"))
original = None
data = {}
hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {}
entries = hooks.get("PostToolUse") if isinstance(hooks.get("PostToolUse"), list) else []
# Strip the managed command everywhere first, then re-append when enabled: the same pass
# handles removal, de-duplication, and matcher migration on version upgrades. The final
# text comparison keeps the write idempotent across boots.
filtered = []
for entry in entries:
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
filtered.append(entry)
continue
kept = [
item
for item in entry["hooks"]
if not (isinstance(item, dict) and item.get("command") == command)
]
if len(kept) != len(entry["hooks"]):
if not kept:
continue
entry = dict(entry)
entry["hooks"] = kept
filtered.append(entry)
entries = filtered
if action == "add":
entries.append({"matcher": matcher, "hooks": [{"type": "command", "command": command}]})
if entries:
hooks["PostToolUse"] = entries
else:
hooks.pop("PostToolUse", None)
if hooks:
data["hooks"] = hooks
else:
data.pop("hooks", None)
serialized = json.dumps(data, indent=2) + "\n"
if serialized != original:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(serialized)
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.

View File

@@ -17,25 +17,54 @@ if ! command -v gh > /dev/null 2>&1; then
exit 0
fi
# Everything below writes into the abc runtime user's HOME, so it must run AS abc. cont-init
# runs as root with HOME already pointing at the persistent data location, so plain
# `git config --global` recreated ~/.gitconfig owned by root:root on every start — and because
# that file is rewritten each boot, 20-folders.sh's earlier recursive chown never stuck to it.
# The user who actually runs git, gh and Claude was then unable to read its own committer
# identity or the gh credential helper, so every commit failed with "Author identity unknown"
# and authenticated pushes fell back to prompting. 20-folders.sh already remapped abc to the
# effective runtime identity (never root in bypass mode), so follow abc rather than re-reading
# the raw PUID/PGID options here.
RUNTIME_UID="$(id -u abc)"
RUNTIME_GID="$(id -g abc)"
run_as_runtime_user() {
s6-setuidgid abc env HOME="$HOME" "$@"
}
# Reclaim any root-owned copies left by an earlier add-on version before writing as abc:
# these paths are not covered by 82-claude_tools.sh's ownership pass, and a root-owned
# ~/.gitconfig would make the first `git config` below fail outright under `set -e`.
mkdir -p "$HOME/.config"
chown -- "${RUNTIME_UID}:${RUNTIME_GID}" "$HOME/.config"
for managed_path in "$HOME/.gitconfig" "$HOME/.config/gh"; do
if [ -e "$managed_path" ]; then
chown -R -- "${RUNTIME_UID}:${RUNTIME_GID}" "$managed_path" || bashio::log.warning "Unable to set ownership on $managed_path"
fi
done
if bashio::config.has_value 'github_username'; then
git config --global user.name "$(bashio::config 'github_username')"
run_as_runtime_user git config --global user.name "$(bashio::config 'github_username')"
fi
if bashio::config.has_value 'github_email'; then
git config --global user.email "$(bashio::config 'github_email')"
run_as_runtime_user git config --global user.email "$(bashio::config 'github_email')"
fi
if bashio::config.has_value 'github_token'; then
token="$(bashio::config 'github_token')"
mkdir -p "$HOME/.config/gh"
chmod 700 "$HOME/.config/gh"
if env -u GH_TOKEN -u GITHUB_TOKEN gh auth status --hostname github.com > /dev/null 2>&1; then
run_as_runtime_user mkdir -p "$HOME/.config/gh"
run_as_runtime_user chmod 700 "$HOME/.config/gh"
if run_as_runtime_user env -u GH_TOKEN -u GITHUB_TOKEN gh auth status --hostname github.com > /dev/null 2>&1; then
bashio::log.info "GitHub CLI already authenticated for github.com"
else
bashio::log.info "Configuring GitHub CLI authentication for github.com"
printf '%s\n' "$token" | env -u GH_TOKEN -u GITHUB_TOKEN gh auth login --hostname github.com --with-token || bashio::log.warning "GitHub CLI authentication failed"
printf '%s\n' "$token" | run_as_runtime_user env -u GH_TOKEN -u GITHUB_TOKEN gh auth login --hostname github.com --with-token || bashio::log.warning "GitHub CLI authentication failed"
fi
env -u GH_TOKEN -u GITHUB_TOKEN gh auth setup-git --hostname github.com || bashio::log.warning "GitHub CLI git credential setup failed"
run_as_runtime_user env -u GH_TOKEN -u GITHUB_TOKEN gh auth setup-git --hostname github.com || bashio::log.warning "GitHub CLI git credential setup failed"
else
bashio::log.info "GitHub CLI available. Set github_token to authenticate gh and git operations."
fi

View File

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

View File

@@ -0,0 +1,218 @@
#!/lsiopy/bin/python3
"""Claude Code PostToolUse hook: auto-compress large tool outputs through Headroom.
Registered in ~/.claude/settings.json by 82-claude_tools.sh (managed entry, matcher
"Bash|Grep|Glob|WebFetch"). Desktop-spawned Claude Code sessions cannot be routed
through the Headroom proxy (the Electron app pins ANTHROPIC_BASE_URL to the
production endpoint, headroom #869), so compression there used to depend on the
model voluntarily calling the headroom MCP tools. This hook makes it automatic for
every session type: when a matched tool returns a large output, the hook compresses
it with Headroom's rule-based pipeline and replaces the tool output via
hookSpecificOutput.updatedToolOutput, appending a retrieval marker. The original is
stored in Headroom's shared CCR store (SQLite at ~/.headroom/ccr_store.db — the
same store the headroom MCP server reads), so the model can always get the full
output back with mcp__headroom__headroom_retrieve.
Design constraints:
- Fail open: any error or non-compressible payload exits 0 with no output, leaving
the tool result untouched. A hook crash must never break a session.
- Fast path first: the payload is inspected before importing headroom (~0.6 s);
small outputs never pay the import cost.
- ML text compression (Kompress) is disabled: its model loads in the background,
which never completes inside a short-lived hook process. The rule-based
transforms (SmartCrusher for JSON, search/log/diff/tabular compressors) carry
the savings on tool output anyway; plain prose passes through unchanged.
- stderr fields are never compressed — error text must reach the model verbatim
(matching Headroom's own error-protection policy).
- File-list arrays (Glob's `filenames`, Grep's `filenames` in files_with_matches
mode — both typed `string[]` by the CLI's own output schema) are handled
separately from prose/JSON-blob fields: Headroom's SmartCrusher subsamples
JSON arrays for informational dumps, which is fine for e.g. a list of sensor
states but silently drops most paths from a file listing the model needs to
act on. Those fields are truncated deterministically instead (keep the first
N entries, append one marker string) so the model always sees a labeled cut
point rather than a shorter list it might mistake for the complete result.
"""
import json
import os
import sys
def _int_env(name: str, default: str) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return int(default)
MIN_CHARS = _int_env("HEADROOM_HOOK_MIN_CHARS", "4000")
MIN_SAVED_TOKENS = _int_env("HEADROOM_HOOK_MIN_SAVED_TOKENS", "50")
ARRAY_KEEP = _int_env("HEADROOM_HOOK_ARRAY_KEEP", "40")
TTL_SECONDS = 3600 # matches the headroom MCP server's session TTL
SKIP_KEYS = {"stderr"}
def self_test() -> int:
"""Exit 0 when the interpreter can import headroom (used at registration time)."""
try:
import headroom # noqa: F401
return 0
except Exception:
return 1
def main() -> int:
if os.environ.get("HEADROOM_HOOK_DISABLE"):
return 0
try:
payload = json.load(sys.stdin)
except Exception:
return 0
if not isinstance(payload, dict):
return 0
response = payload.get("tool_response")
# Find big string/array fields before paying the headroom import cost.
def is_string_array(value):
return isinstance(value, list) and len(value) > ARRAY_KEEP and all(isinstance(v, str) for v in value)
if isinstance(response, str):
string_candidates = ["__whole__"] if len(response) >= MIN_CHARS else []
array_candidates = []
elif isinstance(response, dict):
string_candidates = [
key
for key, value in response.items()
if key not in SKIP_KEYS and isinstance(value, str) and len(value) >= MIN_CHARS
]
array_candidates = [
key for key, value in response.items() if key not in SKIP_KEYS and is_string_array(value)
]
else:
string_candidates = []
array_candidates = []
if not string_candidates and not array_candidates:
return 0
# Keep Kompress's cache probe away from the tmpfs-backed ~/.cache default.
os.environ.setdefault("HF_HOME", os.path.expanduser("~/.headroom/hf"))
from headroom import savings_ledger
from headroom.cache.compression_store import get_compression_store
from headroom.compress import compress
store = None
totals = [0, 0] # tokens before, tokens after (only for rewritten fields)
def shrink(text):
nonlocal store
result = compress(
[{"role": "tool", "content": text}],
protect_recent=0,
kompress_model="disabled",
)
compressed = result.messages[0].get("content")
if not isinstance(compressed, str):
compressed = json.dumps(compressed)
saved = result.tokens_before - result.tokens_after
if saved < MIN_SAVED_TOKENS:
return None
if store is None:
store = get_compression_store()
hash_key = store.store(
original=text,
compressed=compressed,
original_tokens=result.tokens_before,
compressed_tokens=result.tokens_after,
compression_strategy="posttooluse_hook",
ttl=TTL_SECONDS,
)
totals[0] += result.tokens_before
totals[1] += result.tokens_after
return (
f"{compressed}\n"
f"[headroom: output compressed {result.tokens_before}->{result.tokens_after} tokens; "
f"call mcp__headroom__headroom_retrieve with hash={hash_key} if you need the full original]"
)
def shrink_array(items):
nonlocal store
original_json = json.dumps(items)
if len(original_json) < MIN_CHARS:
return None
kept = items[:ARRAY_KEEP]
truncated_json = json.dumps(kept)
# No ML/token-counter call needed for a plain truncation decision; a char/4
# estimate is the same fallback Headroom's own cost estimator uses and is
# only used here to decide eligibility and annotate the marker.
tokens_before = max(1, len(original_json) // 4)
tokens_after = max(1, len(truncated_json) // 4)
if tokens_before - tokens_after < MIN_SAVED_TOKENS:
return None
if store is None:
store = get_compression_store()
hash_key = store.store(
original=original_json,
compressed=truncated_json,
original_tokens=tokens_before,
compressed_tokens=tokens_after,
compression_strategy="posttooluse_hook_array_truncate",
ttl=TTL_SECONDS,
)
totals[0] += tokens_before
totals[1] += tokens_after
remaining = len(items) - len(kept)
marker = (
f"[headroom: {remaining} more of {len(items)} entries omitted "
f"({tokens_before}->{tokens_after} tokens); call mcp__headroom__headroom_retrieve "
f"with hash={hash_key} for the complete list]"
)
return kept + [marker]
updated = None
if isinstance(response, str):
updated = shrink(response)
else:
rewritten = dict(response)
changed = False
for key in string_candidates:
new_value = shrink(rewritten[key])
if new_value is not None:
rewritten[key] = new_value
changed = True
for key in array_candidates:
new_value = shrink_array(rewritten[key])
if new_value is not None:
rewritten[key] = new_value
changed = True
if changed:
updated = rewritten
if updated is None:
return 0
savings_ledger.record_savings_event(
tokens_before=totals[0],
tokens_after=totals[1],
client="posttooluse-hook",
source="hook",
)
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": updated,
}
},
sys.stdout,
)
return 0
if __name__ == "__main__":
if "--self-test" in sys.argv:
sys.exit(self_test())
try:
sys.exit(main())
except Exception:
sys.exit(0)

View File

@@ -1,3 +1,5 @@
## 1.4.0-2 (16-07-2026)
- Minor bugs fixed
## 1.4.0 (2026-06-20)
- Update to latest version from gtsteffaniak/filebrowser (changelog : https://github.com/gtsteffaniak/filebrowser/releases)

View File

@@ -115,4 +115,5 @@ LABEL \
# 6 Healthcheck #
#################
# Upstream
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1

View File

@@ -114,4 +114,4 @@ schema:
slug: filebrowser_quantum
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "1.4.0"
version: "1.4.0-2"