Merge pull request #2871 from alexbelgium/feat/headroom-posttooluse-hook

feat(claude_desktop): auto-compress large tool outputs via Headroom PostToolUse hook
This commit is contained in:
Alexandre
2026-07-16 14:53:16 +02:00
committed by GitHub
5 changed files with 322 additions and 1 deletions

View File

@@ -1,3 +1,7 @@
## 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.

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.29"
version: "1.30"
video: true

View File

@@ -385,6 +385,92 @@ if changed:
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

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