Fixes#2135.
## Summary
`SharedContext.put` ran `_evict_if_needed` before writing, and the
eviction loop only checked `len(self._entries) >= self._max_entries`.
When a caller updated a key that was already cached at capacity, the put
would not have grown the map — but the loop still dropped the oldest
unrelated entry.
Same defect class as fixed for `SemanticCache` in #2094: the eviction
path must know the incoming key so an update is not treated as an
insert. This mirrors that fix over to `SharedContext`.
Threads the incoming key through `_evict_if_needed` and skips capacity
eviction when it names an entry that already exists. Expired-entry
cleanup still runs unconditionally.
Issue #2135 has the reproduction and impact writeup.
## Test plan
- [x] `uv run pytest tests/test_shared_context.py` — 16 passed (added
`test_updating_existing_key_at_capacity_does_not_evict`).
- [x] `uv run ruff check headroom/shared_context.py
tests/test_shared_context.py` — clean.
- [x] `uv run ruff format --check headroom/shared_context.py
tests/test_shared_context.py` — already formatted.
## Real behavior proof
**Setup:** macOS 25.4 (Darwin arm64), Python 3.12.13, `uv 0.11.28`, this
branch (`fix/shared-context-evict-on-update`).
**Before the patch (unpatched `main`)**
\`\`\`
before update: ['a', 'b', 'c']
after update: ['b', 'c'] # <-- 'a' evicted, even though 'c' was an
update
\`\`\`
**After the patch (this branch)**
\`\`\`
\$ uv run python <<'PY'
from headroom.shared_context import SharedContext
ctx = SharedContext(ttl=3600, max_entries=3)
ctx.put(\"a\", \"x\"*400)
ctx.put(\"b\", \"x\"*400)
ctx.put(\"c\", \"x\"*400)
print(\"before update:\", sorted(ctx.keys()))
ctx.put(\"c\", \"y\"*400) # update existing at capacity
print(\"after update: \", sorted(ctx.keys()))
print(\"c value:\", ctx.get(\"c\", full=True)[:12] + \"...\")
PY
before update: ['a', 'b', 'c']
after update: ['a', 'b', 'c']
c value: yyyyyyyyyyyy...
\`\`\`
**Test output**
\`\`\`
\$ uv run pytest tests/test_shared_context.py -q
................ [100%]
16 passed in 2.17s
\`\`\`
**What I did NOT test**
- Multi-thread test — the fix is inside the existing `self._lock`, so
serialization semantics are unchanged; I did not add a concurrent-put
stress test.
- Interaction with TTL expiry AND capacity in one call — the existing
`test_evicts_oldest_at_capacity` and `test_expired_entry_returns_none`
still pass, but I did not add a combined case.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
- SharedContext: compressed inter-agent context sharing via put()/get()
over existing CCR compression pipeline. Zero new dependencies.
- README rewrite: lead with "any agent" positioning, not just coding
agents. Add headroom wrap, SharedContext, MCP tools to Quick Start.
Reorder integration table: universal first, coding shortcuts last.
Update compression pipeline references (LLMLingua → Kompress).
- Fix proxy cleanup in headroom wrap: don't kill shared proxy if other
clients are still using it (was orphaning terminals 2-N).
- New docs: docs/shared-context.md