These three wiki pages documented shipped features (PRs #139, #145, #191)
but were never ported into the new Fumadocs site introduced in 911eb85
("new docs UI + ts doc coverage"). Users browsing docs.* couldn't find
the Docker-native install flow, the canonical filesystem contract, or
the persistent-install CLI surface.
- Add docs/content/docs/docker-install.mdx (one-line installer, native
wrapper behavior, persistent-docker lifecycle, Compose runtime).
- Add docs/content/docs/filesystem-contract.mdx (two-root model,
precedence, bucket assignments, Docker overlap between
HEADROOM_WORKSPACE and HEADROOM_WORKSPACE_DIR).
- Add docs/content/docs/persistent-installs.mdx (runtime matrix,
presets, scopes, health/wrap behavior, Docker-native relationship).
- installation.mdx: add a Callout in the Docker section linking to the
new docker-install page so pip/npm/docker landing users find it.
- meta.json: surface the three new pages in the sidebar under
Getting Started and Configuration.
Wiki source files in wiki/ are left in place for now; they can be
deprecated in a follow-up once the new site is confirmed as canonical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move importorskip after playwright import so module-level
import error triggers skip rather than collection error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add browser/page fixtures and importorskip guard so dashboard
E2E tests are skipped (not failed) when playwright is not installed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New /transformations/feed endpoint returning message diffs
- Alpine.js drawer UI with virtual scrolling and auto-stream pause
- Live Feed button hidden when log_full_messages=false
- Added --log-messages CLI flag to enable full message logging
- Backend stores request/response messages when enabled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7 new "important" findings from the third review pass:
1. base.py:158 — JSONDecodeError on OpenAI tool_call arguments is now
logged at DEBUG (was silently replaced with {}). Callers can now
diagnose why range-key checks didn't fire on malformed calls.
2. base.py:234,248 — logger.warning for interceptor transform() and
key() failures now passes `exc_info=True` so stack traces are
preserved in logs.
3. base.py:306 — progressive disclosure now pre-seeds `fired` from the
frozen prefix. A file first Read in the cached prefix no longer gets
re-outlined when the model Reads it again in the mutable tail.
apply_to_messages() now takes a `frozen_count` kwarg and handles the
split internally; the Transform adapter simplifies.
4. astgrep.py:114 — broadened `except` around binaries.resolve() to
catch the full BinaryError hierarchy plus KeyError + OSError. In
offline mode this is the difference between a debug log and a
warning on every single request.
5. astgrep.py:181 — chmod 0700 failure on the temp dir now logs at
DEBUG. Silent swallow meant a multi-tenant host could leave
untrusted content world-readable with no indication the hardening
skipped.
6. cli/proxy.py:297 — explicit `--intercept-tool-results` now fails
fast (`sys.exit(1)`) when the critical tool can't be installed.
Previously it warned and started with non-functional interceptors.
7. pipeline.py:85 — interceptor gate now checks
`HeadroomConfig.intercept_tool_results` first, env var second.
Non-CLI callers (SDK, tests, embedded) can enable via config
instead of having to touch os.environ. Added the config field with
default False.
Plus a new test: `test_progressive_disclosure_respects_frozen_prefix_history`
proves a file Read in the frozen prefix blocks re-outline in the tail.
46 tests total, ruff + mypy clean.
Three test files merged in #196 via Pi/Codex route-aliases PR failed
ruff format --check, blocking CI on this PR. Auto-formatting them here
so the branch passes. No logic changes — only whitespace / trailing
comma normalization that ruff format applies.
Two bugs collided to break the Docker-native install CI:
1. `ensure_tools()` ran unconditionally at every proxy startup, even when
`--intercept-tool-results` was not passed. The feature is opt-in, so
there's no reason to pay the binary-fetch cost (or risk a failure) when
nothing will use them.
2. The fetch loop caught PlatformNotSupported / OfflineError /
BinaryFetchError / Sha256Mismatch but not `PermissionError`. In
containerized environments where the home dir / cache dir isn't
writable, `binary_path.parent.mkdir()` raises PermissionError
(subclass of OSError), which propagated out of ensure_tools() and
crashed proxy startup.
Fixes:
- Move ensure_tools() inside the `if intercept_tool_results:` branch in
cli/proxy.py so the base case never triggers a fetch.
- Catch OSError (covers PermissionError, ENOSPC, etc.) in ensure_tools()
so sandboxed / readonly filesystems degrade to no-op instead of
crashing. Interceptors fall back to pass-through when their tool isn't
resolvable.
Adds regression test `test_ensure_tools_survives_readonly_cache_dir` that
points the cache at a chmod-0500 parent and asserts ensure_tools()
returns without raising.
Addresses all 24 inline comments across the two review passes.
**CRITICAL fixes:**
- binaries.py: PID-scoped partial-file name prevents concurrent `headroom proxy`
starts from clobbering each other's downloads.
- binaries.py: strip URL query params before computing the download filename
(was breaking archive-type detection for mirror URLs with `?token=...`).
- cli/tools.py: `--force` cleanup now logs failures and bumps exit_code
instead of silently swallowing exceptions.
**HIGH fixes:**
- binaries.py: log at INFO when SHA256 is unpinned; expose `sha_pinned` in
doctor's status output.
- proxy/interceptors/base.py: `_FAILURES` counter + `interceptor_failure_counts()`
getter; incremented on every `matches()`/`transform()`/`key()` exception so
dashboards can distinguish "nothing eligible" from "everything crashing".
- cli/proxy.py: validate critical tools resolved when
`--intercept-tool-results` is set; warn (don't fail) if a dependency is
missing.
- proxy/interceptors/base.py: compute `tokens_before` from the original
messages via `count_messages()` instead of back-calculating from
`tokens_after + sum(saved)` (which double-counted message-level overhead).
- proxy/interceptors/astgrep.py: write untrusted tool_output into a private
mode-0700 `tempfile.mkdtemp()` directory, not directly into shared `/tmp`.
- proxy/interceptors/base.py: `ToolResultInterceptorTransform.apply()` now
honors `frozen_message_count` — leading cached-prefix messages are passed
through untouched to preserve provider prefix caches.
**MEDIUM fixes:**
- proxy/interceptors/base.py: pre-built O(1) tool_use index replaces the
O(n²) per-tool-result linear scan.
- proxy/interceptors/base.py: broken `progressive_disclosure_key()` now
skips the interceptor entirely rather than firing without key protection.
- proxy/interceptors/astgrep.py: distinguish ast-grep rc=1 (no matches) from
rc>=2 (real errors — bad syntax, missing grammar, corrupt binary).
- proxy/interceptors/astgrep.py: count JSON parse failures; warn when all
lines fail to parse (indicates version mismatch).
- binaries.py: musl detection falls back to checking `/lib/ld-musl-*.so.1`
when `ldd` is absent (Alpine).
- proxy/interceptors/astgrep.py: use `tempfile.mkdtemp()` + `shutil.rmtree`
instead of `NamedTemporaryFile(delete=False)`; cleans up on Windows.
- binaries.py: chmod failures on POSIX now log a warning (only swallow on
Windows where .exe is implicitly executable).
- tests/test_binaries.py: `test_mirror_substitution` now uses
`monkeypatch.setenv()` instead of raw `os.environ` manipulation.
- tools.json: add `linux-x86_64-musl` and `linux-aarch64-musl` entries for
`difft`; document the shared-asset strategy for both tools.
- proxy/interceptors/astgrep.py: log a debug line when
`progressive_disclosure_key()` returns None for a tool whose tool_input
shape we don't recognize.
- proxy/interceptors/base.py: moved `import json` to module top (was inside
`_find_tool_use` hot loop).
- binaries.py: fix bare `.gz` detection — now explicitly excludes
`.tar.gz`/`.tgz` instead of relying on a brittle "no dots" heuristic.
- proxy/interceptors/astgrep.py: provenance comment on each `_RANGE_KEYS`
entry so future maintainers know which tool defined which key.
- cli/tools.py: comment explaining os.execv's lack of Python finalizer
cleanup.
- proxy/interceptors/base.py: `InterceptionResult` now `frozen=True`.
**Test gaps closed:**
- Interceptor failure isolation (transform() raises → request survives,
counter increments).
- Broken key() skips interceptor entirely.
- Refuse-to-enlarge guard (rewrite larger than original → pass through).
- Orphaned tool_result (no matching tool_use) doesn't crash.
- ToolResultInterceptorTransform.apply() happy path + frozen_message_count.
- ensure_tools() partial failure (one tool fetch fails, others succeed,
proxy still starts).
- Mirror URL with query params doesn't leak into download filename.
44 tests total; ruff + mypy clean.
What this does, in plain terms:
Headroom's proxy now ships with three CLI tools (ast-grep, difftastic,
scc) that it can use to shrink tool_result payloads before they reach
the model. The goal is simple: when Claude Code (or Codex, Aider, etc.)
asks the model to reason about a big file or diff, we swap the verbose
output for a compact, same-meaning version. Fewer tokens per turn, same
answers, lower bill.
Today a single interceptor is wired: ast-grep on Read. When an agent
reads a large code file, the proxy replaces the file body with an
outline of its top-level functions/classes plus docstrings. In live
tests that cut prompt tokens 74–76% on both OpenAI and Anthropic,
same answer either way.
How it works:
- `pip install headroom-ai` now installs ast-grep via a PyPI wheel
(core dep). difftastic and scc are fetched once at proxy startup
from pinned upstream GitHub releases and cached per-user.
- A generic registry (`headroom/proxy/interceptors/`) lets us add more
tool-aware rewrites in one file each: declare `matches()` and
`transform()`, call `register()`, done. No proxy or metrics plumbing
per tool.
- Safety rails built in: pass-through when a Read specifies a line
range; second Read of the same file in a conversation returns full
content (progressive disclosure); any failing interceptor logs and
skips, never crashes a request.
Opt-in for now:
- Off by default while this ships. Turn on with
`headroom proxy --intercept-tool-results` or
`HEADROOM_INTERCEPT_ENABLED=1`, so we can measure before flipping
defaults.
What users see after turning it on:
- First `headroom wrap claude` boot is ~5s longer (binaries fetched).
Every subsequent run is cache-only.
- Existing `transforms_applied` field in metrics gets entries like
`interceptor:ast-grep`, so savings show up in current dashboards
and HTML reports with no UI change.
Other housekeeping in this PR:
- uv.lock moved to .gitignore — regenerated locally per environment.
- 35 unit + integration tests, ruff + mypy clean.
- Dead-code audit done: removed `binaries.run()`, `needs_filesystem`
plumbing, unused `_kind` tuple elements, unused `tool_output`
parameter, and the never-set HEADROOM_SKIP_TOOLS_BOOTSTRAP env.