NEXO 7.8.0 — compaction continuity closed end-to-end
Published 2026-04-22. Minor release over v7.7.0.
v7.7 closed the six obedience gaps from the constructor-guardian-90 pass. Francisco flagged the next load-bearing piece right after: PostCompact continuity. The shell script already existed but was never actually registered, so compaction in multi-conversation Desktop was fragile in concrete ways — wrong conv restored, cross-conv leaks possible, the map's pre_compaction / post_compaction on_event rules never fired from the live stream. v7.8 closes all of that.
PostCompact is a real hook now
New wrapper src/hooks/post_compact.py mirrors pre_compact.py. It delegates to post-compact.sh, proxies the shell's stdout verbatim (so Claude Code still receives the systemMessage JSON the shell emits), and records a hook_runs row with the real CLAUDE_SESSION_ID for audit. Both manifests register the new event:
src/hooks/manifest.json— canonical set grows from 8 to 9 hooks.test_manifest_has_nine_hookspins the floor.hooks/hooks.json— the runtime plugin block Claude Code reads now declares thePostCompactmatcher with a 15 s timeout.
Exact session targeting
Pre-v7.8 pre-compact.sh resolved the session like this:
LATEST_SID=$(sqlite3 ... "SELECT sid FROM sessions ORDER BY last_update_epoch DESC LIMIT 1")
That was actively wrong in multi-conversation Desktop. The "latest active" session frequently belonged to a different conversation than the one Claude Code was compacting in this specific hook invocation.
v7.8 uses CLAUDE_SESSION_ID — the token Claude Code passes to every hook — and resolves it to a NEXO SID via sessions.claude_session_id (primary) or session_claude_aliases (fallback). No LATEST_SID. The sidecar also moves out of /tmp into $NEXO_HOME/runtime/data/compacting-sid.txt so two concurrent compactions on two conversations cannot race on /tmp.
Fail-closed, not fall-back-to-latest
The pre-v7.8 post-compact.sh had a silent "latest checkpoint" fallback: if no exact SID was found, it restored whatever checkpoint was most recently updated. That meant conversation A could hydrate conversation B's state. v7.8 removes that fallback. When the exact SID is missing or mismatches the env-resolved one, the hook emits a small diagnostic systemMessage ("SID mismatch: skipping rehydration to avoid cross-conv leak" or "no checkpoint for this exact session") and exits cleanly. Restoring nothing is better than restoring the wrong conversation.
Additionally the hook cross-checks the sidecar SID against the env-resolved SID. If they disagree, we log a post_compaction event with status: mismatch for observability and refuse the restore.
on_event rules finally fire from the live stream
Hooks run in separate processes so they cannot call EnforcementEngine.raise_event() directly. v7.8 closes that gap with a small NDJSON queue:
- Pre-compact appends
{"event":"pre_compaction","session_id":"nexo-...","claude_session_id":"...","timestamp":...}to$NEXO_HOME/runtime/data/pending_enforcer_events.ndjson. - Post-compact does the same with
event=post_compaction(andstatus=restored,status=mismatch, orstatus=no_targetdepending on what happened). - Engine's new
_consume_pending_hook_events()drains the queue on every periodic tick and fires the matchingon_eventrule. The queue file is truncated after read so a row never fires twice.
This is the bridge that lets the map's pre_compaction → nexo_checkpoint_save and post_compaction → nexo_checkpoint_read rules actually run in the live stream, not only via test harnesses.
compaction_count honesty
The counter now increments only inside the real-restore branch of post-compact.sh (the if [ -n "$CHECKPOINT" ] guard). A dedicated test (test_compaction_count_is_incremented_only_on_real_restore) walks the source and asserts the UPDATE session_checkpoints SET compaction_count = compaction_count + 1 statement lives under the successful-restore path, not under the empty fallback. This was the existing behaviour — the test now pins it so a future refactor cannot quietly start ticking on failed restores.
Tests
New file tests/test_v78_compaction_continuity.py pins 11 invariants across 10 rails:
- PostCompact in
src/hooks/manifest.json(handler + critical flag). - PostCompact in
hooks/hooks.json(runtime command referencespost_compact.py). post_compact.pyexists and proxies shell stdout.pre-compact.shuses CLAUDE_SESSION_ID, no LATEST_SID assignment, no/tmpsidecar, writes NEXO-scoped sidecar.post-compact.shhas no "ORDER BY updated_at DESC LIMIT 1" fallback, reads the NEXO-scoped sidecar.post-compact.shemits a "SID mismatch" diagnostic.- Both hooks emit
pending_enforcer_events.ndjsonrows. - Engine implements
_consume_pending_hook_eventsand calls it fromcheck_periodic. - Engine consumer truncates the queue file after read.
- post_compact.py records
hook_runswith CLAUDE_SESSION_ID. compaction_countincrements only in the real-restore branch.
Pytest 2086 passing (+16 vs v7.7.0). test_manifest_has_nine_hooks replaces the old 8-hook floor.
No Desktop bump
Everything in v7.8 is server-side (Brain hooks + engine consumer). Desktop's MCP contract is unchanged; v0.27.0 continues to ship.
Install verification
Once you run nexo update to 7.8.0, the new PostCompact block is synced into both:
~/.claude/settings.json(the file Claude Code reads on boot) — via client_sync.~/.nexo/runtime/operations/hooks_status.json— via the sync path that records hook health.
Compact a conversation in Desktop and you should see a post_compact row appear in hook_runs with the correct session_id. If you compact a conv whose SID mismatches the sidecar (edge case where two conversations raced), the systemMessage will say so instead of pretending the restore worked.
Related: full v7.8.0 changelog · v7.7.0 release notes · source on GitHub