fix(hooks): restore silent-save visibility on Claude Code 2.1.114#1021
Conversation
hooks_cli: add continue:true, suppressOutput:false alongside systemMessage for the full Stop hook schema documented in Claude Code's hook output format. Doesn't change behaviour (defaults match), but leaves no field implicit in case the renderer starts requiring the full shape. README: document that Claude Code 2.1.114 regressed the systemMessage Line rendering — the `✦ N memories woven` notification flashes for ~1s and is then discarded instead of persisting as a visible Line. Saves complete correctly; only the UX is gone. Tracked upstream at anthropics/claude-code#50542. Fork producer-side fixes in MemPalace#1021. Workaround: hook_desktop_toast for notify-send popups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the Python hook implementation to keep Claude Code stop-hook output visible/parseable after changes in Claude Code 2.1.114 by ensuring hook JSON is written to “real” stdout and by refining the stop_hook_active guard so it only suppresses behavior in block-mode.
Changes:
- Update
_output()to write JSON to the original stdout file descriptor (when available) rather than the potentially redirectedsys.stdout. - Change
hook_stop()so thestop_hook_activeearly-return guard is applied only whenhook_silent_saveis disabled (block mode).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| """Print JSON to the real stdout, even if mcp_server has hijacked sys.stdout. | ||
|
|
||
| mempalace.mcp_server redirects stdout → stderr at module import (fd and | ||
| sys-level) to protect the MCP stdio protocol from ChromaDB's C-level | ||
| prints. Silent-save imports it transitively via _save_diary_direct, so | ||
| sys.stdout is stderr by the time we get here. Claude Code reads hook | ||
| output from fd 1, so we write there directly using the saved fd. | ||
| """ | ||
| payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" | ||
| try: | ||
| from .mcp_server import _REAL_STDOUT_FD | ||
| if _REAL_STDOUT_FD is not None: | ||
| os.write(_REAL_STDOUT_FD, payload.encode("utf-8")) | ||
| return | ||
| except Exception: | ||
| pass | ||
| sys.stdout.write(payload) |
There was a problem hiding this comment.
_output() unconditionally importing mempalace.mcp_server has significant side effects: that module redirects sys.stdout to sys.stderr at import time and pulls in heavy dependencies. In environments where fd-level dup/dup2 isn’t available (mcp_server sets _REAL_STDOUT_FD=None), the fallback path here will then write the hook JSON to sys.stdout (now stderr), breaking Claude Code’s expectation that hook responses are on fd 1. Consider avoiding the import: read _REAL_STDOUT_FD only if mempalace.mcp_server is already loaded (via sys.modules), otherwise write to fd 1 directly (or maintain your own saved “real stdout” fd captured at process start).
| """Print JSON to the real stdout, even if mcp_server has hijacked sys.stdout. | |
| mempalace.mcp_server redirects stdout → stderr at module import (fd and | |
| sys-level) to protect the MCP stdio protocol from ChromaDB's C-level | |
| prints. Silent-save imports it transitively via _save_diary_direct, so | |
| sys.stdout is stderr by the time we get here. Claude Code reads hook | |
| output from fd 1, so we write there directly using the saved fd. | |
| """ | |
| payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" | |
| try: | |
| from .mcp_server import _REAL_STDOUT_FD | |
| if _REAL_STDOUT_FD is not None: | |
| os.write(_REAL_STDOUT_FD, payload.encode("utf-8")) | |
| return | |
| except Exception: | |
| pass | |
| sys.stdout.write(payload) | |
| """Print JSON to stdout without importing modules that may redirect streams. | |
| If mempalace.mcp_server is already loaded, reuse its saved real stdout fd. | |
| Otherwise, write directly to fd 1 so hook responses still go to stdout even | |
| if sys.stdout has been redirected elsewhere. | |
| """ | |
| payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8") | |
| real_stdout_fd = None | |
| mcp_server_module = sys.modules.get("mempalace.mcp_server") or sys.modules.get( | |
| f"{__package__}.mcp_server" if __package__ else "mcp_server" | |
| ) | |
| if mcp_server_module is not None: | |
| real_stdout_fd = getattr(mcp_server_module, "_REAL_STDOUT_FD", None) | |
| try: | |
| os.write(real_stdout_fd if real_stdout_fd is not None else 1, payload) | |
| return | |
| except OSError: | |
| pass | |
| sys.stdout.buffer.write(payload) |
| payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" | ||
| try: | ||
| from .mcp_server import _REAL_STDOUT_FD | ||
| if _REAL_STDOUT_FD is not None: | ||
| os.write(_REAL_STDOUT_FD, payload.encode("utf-8")) | ||
| return |
There was a problem hiding this comment.
When writing via os.write(), it’s possible (though uncommon) to get partial writes or EINTR. To make hook output robust, consider looping until the entire payload is written (and handling InterruptedError) rather than assuming a single os.write() writes all bytes.
| mempalace.mcp_server redirects stdout → stderr at module import (fd and | ||
| sys-level) to protect the MCP stdio protocol from ChromaDB's C-level | ||
| prints. Silent-save imports it transitively via _save_diary_direct, so | ||
| sys.stdout is stderr by the time we get here. Claude Code reads hook | ||
| output from fd 1, so we write there directly using the saved fd. |
There was a problem hiding this comment.
The new _output() docstring references a silent-save import path via “_save_diary_direct”, but that function doesn’t exist in this module or elsewhere in the repo. This makes the explanation misleading; please update the wording to match the actual call/import chain that causes mcp_server to be imported, or remove the reference.
| mempalace.mcp_server redirects stdout → stderr at module import (fd and | |
| sys-level) to protect the MCP stdio protocol from ChromaDB's C-level | |
| prints. Silent-save imports it transitively via _save_diary_direct, so | |
| sys.stdout is stderr by the time we get here. Claude Code reads hook | |
| output from fd 1, so we write there directly using the saved fd. | |
| Some hook code paths may already have imported ``mempalace.mcp_server``. | |
| That module redirects stdout → stderr at import time (both fd- and | |
| sys-level) to protect the MCP stdio protocol from ChromaDB's C-level | |
| prints. When that has already happened, ``sys.stdout`` no longer points | |
| at the hook response stream, so Claude Code will only see output written | |
| to the saved original stdout fd exposed as ``_REAL_STDOUT_FD``. |
| try: | ||
| from .config import MempalaceConfig | ||
| silent_guard = MempalaceConfig().hook_silent_save | ||
| except Exception: | ||
| silent_guard = True |
There was a problem hiding this comment.
In the stop_hook_active guard, the broad except defaulting silent_guard=True means the infinite-loop prevention is disabled if config import/load fails (falling through to normal processing). If this code path is intended as a safety guard, consider narrowing the exception and defaulting to the previous behavior (treat as block mode / keep the guard) when the setting can’t be determined, or at least logging a warning so repeated Stop fires don’t unexpectedly change behavior.
| try: | |
| from .config import MempalaceConfig | |
| silent_guard = MempalaceConfig().hook_silent_save | |
| except Exception: | |
| silent_guard = True | |
| silent_guard = False | |
| try: | |
| from .config import MempalaceConfig | |
| except ImportError as exc: | |
| _log( | |
| f"WARNING: Failed to import MempalaceConfig while evaluating stop hook guard: {exc}. " | |
| "Preserving block-mode guard behavior." | |
| ) | |
| else: | |
| try: | |
| silent_guard = MempalaceConfig().hook_silent_save | |
| except AttributeError as exc: | |
| _log( | |
| f"WARNING: Failed to read hook_silent_save while evaluating stop hook guard: {exc}. " | |
| "Preserving block-mode guard behavior." | |
| ) |
| try: | ||
| from .mcp_server import _REAL_STDOUT_FD | ||
| if _REAL_STDOUT_FD is not None: | ||
| os.write(_REAL_STDOUT_FD, payload.encode("utf-8")) | ||
| return | ||
| except Exception: | ||
| pass | ||
| sys.stdout.write(payload) | ||
| sys.stdout.flush() | ||
|
|
||
|
|
There was a problem hiding this comment.
There are unit tests for hook_stop(), but they patch _output(), so the new “write to real stdout fd” behavior isn’t exercised. Adding a regression test that imports mempalace.mcp_server (to trigger stdout redirection) and then asserts _output() still emits JSON on captured stdout (and not stderr) would help prevent this from breaking again across Claude Code/plugin dispatch changes.
| try: | |
| from .mcp_server import _REAL_STDOUT_FD | |
| if _REAL_STDOUT_FD is not None: | |
| os.write(_REAL_STDOUT_FD, payload.encode("utf-8")) | |
| return | |
| except Exception: | |
| pass | |
| sys.stdout.write(payload) | |
| sys.stdout.flush() | |
| payload_bytes = payload.encode("utf-8") | |
| try: | |
| from .mcp_server import _REAL_STDOUT_FD | |
| if _REAL_STDOUT_FD is not None: | |
| os.write(_REAL_STDOUT_FD, payload_bytes) | |
| return | |
| except Exception: | |
| pass | |
| try: | |
| os.write(1, payload_bytes) | |
| return | |
| except OSError: | |
| pass | |
| stream = getattr(sys, "__stdout__", None) or sys.stdout | |
| stream.write(payload) | |
| stream.flush() |
- _output(): use sys.modules.get() instead of unconditional import to avoid triggering mcp_server's stdout redirect as a side effect - _output(): write-all loop for os.write() to handle partial writes and EINTR; fall back to sys.stdout.buffer on OSError - _output() docstring: remove inaccurate _save_diary_direct reference - stop_hook_active guard: narrow except to ImportError/AttributeError, default silent_guard=False (safe: preserves block-mode loop prevention when config load fails) and log a warning instead of silently changing behavior - tests: two new regression tests covering the real-stdout-fd path and the fd-1 fallback path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed all five Copilot review points:
|
- _output(): use sys.modules.get() instead of unconditional import to avoid triggering mcp_server's stdout redirect as a side effect - _output(): write-all loop for os.write() to handle partial writes and EINTR; fall back to sys.stdout.buffer on OSError - _output() docstring: remove inaccurate _save_diary_direct reference - stop_hook_active guard: narrow except to ImportError/AttributeError, default silent_guard=False (safe: preserves block-mode loop prevention when config load fails) and log a warning instead of silently changing behavior - tests: two new regression tests covering the real-stdout-fd path and the fd-1 fallback path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… count 8 open Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude Code 2.1.114 passes stop_hook_active:true on every Stop fire
after the first in a session (plugin-dispatched hooks in particular).
The legacy guard at line 426 was written for block-mode, where a
re-fire with the flag set meant "you already blocked, don't block
again" — correct loop prevention when the hook returns
{"decision":"block"}.
Silent-save mode (default since MemPalace#673) never blocks — it saves
directly and returns. The flag is meaningless there, so the old
guard was suppressing every auto-save after the first one in a
Claude Code session. Symptom: terminal never shows the "✦ N
memories woven" notification again, hook.log stays silent, save
marker stuck.
Fix: only skip on stop_hook_active when block mode is configured.
Silent mode runs through as normal — the save is deterministic and
idempotent, no loop risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rect
mempalace.mcp_server redirects stdout → stderr at module-level import
(both Python-level and fd-level via os.dup2) to protect the MCP stdio
protocol from ChromaDB's C-level noise. Silent-save imports mcp_server
transitively via _save_diary_direct, so by the time _output() calls
print(), sys.stdout is actually stderr.
Claude Code reads hook output from fd 1. With the redirect in effect,
fd 1 points to fd 2, so our {"systemMessage": "✦ N memories woven..."}
JSON lands on stderr and Claude Code never renders it. The save still
happens, the marker still advances — the user just never sees the
beautiful checkpoint notification in their terminal.
Fix: _output() now writes to _REAL_STDOUT_FD (saved by mcp_server before
the redirect) via os.write(), falling back to sys.stdout only when the
saved fd is unavailable (e.g., hooks_cli imported without mcp_server).
Test: bash hook script 2>/dev/null now shows only the JSON;
2>&1 >/dev/null shows only the Diary entry log line — clean separation
restored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- _output(): use sys.modules.get() instead of unconditional import to avoid triggering mcp_server's stdout redirect as a side effect - _output(): write-all loop for os.write() to handle partial writes and EINTR; fall back to sys.stdout.buffer on OSError - _output() docstring: remove inaccurate _save_diary_direct reference - stop_hook_active guard: narrow except to ImportError/AttributeError, default silent_guard=False (safe: preserves block-mode loop prevention when config load fails) and log a warning instead of silently changing behavior - tests: two new regression tests covering the real-stdout-fd path and the fd-1 fallback path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5723f04 to
1531a25
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code reviewFound 1 issue:
mempalace/mempalace/hooks_cli.py Lines 243 to 260 in 2183d86 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
|
@jphein pls see comment above |
…suppress saves Addresses bensig's review on PR MemPalace#1021. silent_guard was initialized to False, so when both MempalaceConfig import and .hook_silent_save attribute access failed, silent_guard stayed False. Then `if not silent_guard:` fired and returned empty — silently dropping the save. In silent mode (the default since v3.3.0), saves should ALWAYS proceed on config-read failure. Changing the initial value to True makes that the safe default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…suppress saves Addresses bensig's review on PR MemPalace#1021. silent_guard was initialized to False, so when both MempalaceConfig import and .hook_silent_save attribute access failed, silent_guard stayed False. Then `if not silent_guard:` fired and returned empty — silently dropping the save. In silent mode (the default since v3.3.0), saves should ALWAYS proceed on config-read failure. Changing the initial value to True makes that the safe default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Good catch, thanks. Fixed in |
…emPalace#1036 Overnight/morning: - MemPalace#681, MemPalace#1000, MemPalace#1023 merged — moved from "Still ahead" to "Merged upstream (post-v3.3.1)" - bensig reviewed MemPalace#659 (wing_ prefix + agent filter) and MemPalace#1021 (silent_guard default) — both addressed on their PR branches - MemPalace#673 needed re-rebase after overnight develop merges; done - MemPalace#1036 filed: paginate miner.status(), closes upstream MemPalace#802 and MemPalace#1015 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Small correction on my earlier comment — the CI matrix is 3 Python versions (3.9, 3.11, 3.13) × 3 platforms, not 2. All still green. |
|
One more correction on the same thread — the "tests still green" claim I made was premature: the CI run for |
… (2026-04-22) Ben's batched queue-clear pass merged four PRs at 00:38 UTC: graph cache (MemPalace#661), deterministic hook saves (MemPalace#673), Claude Code 2.1.114 hook stdout + silent_save guard (MemPalace#1021), and upstream's own MemPalace#851 pagination fix (closing MemPalace#1036 as superseded). Moved four rows out of the "Fork Changes" / "Fork change queue" tables into their respective merged-upstream history sections. Intro sentence PR count reduced from 7 → 4 open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restore-integrity release. Unbreaks fresh `pip install mempalace` from v3.3.2 by re-tagging current develop, which carries both the plugin.json consumer (shipped in 3.3.2) and the matching mempalace-mcp entry point in pyproject.toml (added on develop ~10h after the 3.3.2 tag via MemPalace#340 by @messelink). MemPalace#1093 diagnosed by @jphein. Bumps (all 5 sources agree per Version Guard / CLAUDE.md): - mempalace/version.py 3.3.2 → 3.3.3 - pyproject.toml 3.3.2 → 3.3.3 - .claude-plugin/plugin.json 3.3.2 → 3.3.3 - .claude-plugin/marketplace.json 3.3.2 → 3.3.3 - .codex-plugin/plugin.json 3.3.2 → 3.3.3 - CHANGELOG.md new [3.3.3] entry No code changes. The fix for MemPalace#1093 is already on develop via merged PRs MemPalace#340, MemPalace#1021, MemPalace#851, MemPalace#942, MemPalace#833, MemPalace#673, MemPalace#661, MemPalace#659, MemPalace#1097, MemPalace#1051, MemPalace#1001, MemPalace#945. Branch name intentionally outside the `release/*` ruleset so follow-up CI-fix commits aren't gated behind a nested PR. (Supersedes MemPalace#1143 — closed for exactly that reason after it missed 3 of 5 version files.) Smoke-tested locally from a fresh develop clone: grep mempalace-mcp pyproject.toml .claude-plugin/plugin.json # both ✓ python -m build --wheel # ✓ pip install …-py3-none-any.whl # ✓ which mempalace-mcp # ✓ mempalace-mcp --help # ✓
…safe graph cache 31 commits including: - v3.3.3 release (restores pip install integrity post-v3.3.2 MemPalace#1093 regression) - MemPalace#1097 empty-string filter normalization in mempalace_search - MemPalace#659 diary wing parameter (our fork PR, now upstream) - MemPalace#851/MemPalace#1097/MemPalace#1021/MemPalace#661/MemPalace#673 incorporated - website Crystal Lattice brand refresh - thread-safe graph cache in palace_graph.py Conflict resolutions (10 files): - README.md keep fork version; bump badge 3.3.1 → 3.3.3 for test compat - hooks/README.md keep fork silent/block architecture docs; keep MEMPAL_PYTHON (correct for legacy hook, upstream's rename is stale) - examples/HOOKS_TUTORIAL.md same treatment - mcp_server.py take upstream's sanitize_name(wing) — strictly better than our crude lowercase+underscore normalization - miner.py keep fork 10K batch size + comma formatting on status; adopt upstream's pagination rationale comment - palace_graph.py take upstream entirely — thread-safety improvements layered on top of our MemPalace#661 (which upstream already merged) - hooks_cli.py take upstream (Windows path-separator compat in _wing_from_transcript_path, Codex CLI format in _extract_recent_messages), then re-apply fork-ahead: use _wing_from_transcript_path in _ingest_transcript instead of hardcoded "sessions" — keeps transcript mining coherent with the diary wing derivation from MemPalace#659 - tests/test_hooks_cli.py take upstream's updated wing-kwarg assertions and new test_stop_hook_derives_wing_from_transcript_path; take upstream's mock-based security test (simpler than our three-way assertion, same property tested) Post-merge test state: - 1096 passed, 10 failed in tests/test_claude_plugin_hook_wrappers.py - The 10 failures are the fork-ahead MemPalace#19 divergence already documented in CLAUDE.md: our venv-aware hooks use `dirname`/`cat` which the test's scrubbed-PATH environment doesn't provide. Same class that correctly caught MemPalace#1115 and led us to withdraw it pending MemPalace#1069 arbitration. Expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upstream merged MemPalace#1173, MemPalace#1177, MemPalace#1198, MemPalace#1201 into develop on 2026-04-26 (picked up by the 2026-04-27 develop sync). Strike out their fork-ahead rows in CLAUDE.md and add to the "Merged into upstream" section. Update PR table accordingly: 18 merged (was 14), 7 open (was 8). Bump version note: upstream shipped v3.3.3 on 2026-04-24 (carries our MemPalace#659/MemPalace#1021); v3.3.4 prep branch in flight. README: - "tracks upstream/develop through the 2026-04-27 sync" (was 2026-04-26) - 17 fork-ahead changes (was 22) - 1,510 tests pass on main (was 1,395 — upstream brought new suites) - "Open upstream PRs" 7 entries (was 11) - Drop merged rows from "Fork-ahead — open or pending" table; keep PreCompact recovery-marker row (still fork-only) scripts/check-docs.sh: clean (90 PR refs match upstream state, 13 fork hashes resolve, FORK_CHANGELOG renders clean from YAML). docs/fork-changes.yaml: no edit needed — already had merged_date on the 4 entries from the 2026-04-26 commit `5fd15db`.
Two related silent-save fixes exposed by Claude Code 2.1.114's plugin-scope Stop hook dispatch. Both are producer-side — getting clean JSON with
systemMessageonto fd 1 where Claude Code reads it. The separate UI-side regression that's preventing the<Line>from persisting on screen is filed at anthropics/claude-code#50542.Fix 1 — honor
silent_savewhenstop_hook_active: trueClaude Code 2.1.114 sets
stop_hook_active: trueon every Stop fire after the first in a session (at least for plugin-dispatched hooks). The legacy guard at the top ofhook_stopwas written for block-mode — when the hook returned{"decision":"block"}to ask Claude to save, a re-fire with the flag set meant "you already blocked, don't block again" so we output{}and bailed.Silent-save mode (default since #673) never blocks — it saves directly and returns. The flag is meaningless in that mode, but the old guard was still suppressing every auto-save after the first one in a Claude Code session:
stop_hook_active: false→ saves fine,systemMessageoutputstop_hook_active: true→ legacy guard returns{}, silent no-op, log stays empty, marker stuckThe fix only applies the guard when the user is actually in block mode:
Fix 2 — write hook JSON to real stdout
mempalace.mcp_serverredirects stdout → stderr at module-level import (Python-levelsys.stdout = sys.stderrand fd-levelos.dup2(2, 1)) to protect the MCP JSON-RPC channel from ChromaDB's C-level noise. That redirect was added in #864 and has lived peacefully alongside silent-save for months.Silent-save imports
mcp_servertransitively via_save_diary_direct→from .mcp_server import tool_diary_write. By the time_output()runs,sys.stdoutis stderr, andprint(json.dumps(...))lands on fd 2. Claude Code reads the hook's stdout (fd 1), finds it empty, and never sees thesystemMessagepayload.Historically this didn't bite because… well, I'm honestly not sure. The redirect's been in place since early March, silent-save's been in place since early April, and the
systemMessageline was rendering through at least 2026-04-17. Something in 2.1.114's dispatch path seems stricter — possibly a shift from reading both streams and parsing the first JSON-like chunk to strictly parsing stdout. Either way, writing to the real fd is the correct long-term fix._output()now writes to_REAL_STDOUT_FD(saved bymcp_serverbefore the redirect, exported as a module attribute) viaos.write(), falling back tosys.stdoutonly when unavailable (e.g., hooks_cli used standalone withoutmcp_server). The full schema includingcontinue: true, suppressOutput: falseis emitted for completeness:{ "continue": true, "suppressOutput": false, "systemMessage": "✦ 30 memories woven into the palace — themes" }Test
tests/test_hooks_cli.py).bash hooks/mempal-stop-hook.sh 2>/dev/nullnow shows only the JSON on stdout,2>&1 >/dev/nullshows only the log line on stderr. Cleanly separated streams.UI-side status
The corresponding
<Line>rendering regression in Claude Code 2.1.114 is tracked at anthropics/claude-code#50542. That one's not fixable from our side; this PR ensures we're producing correct output once the client fix lands.Test plan
python -m pytest tests/test_hooks_cli.py -q— 58 tests passdiary_*drawers land at the 15-exchange mark🤖 Generated with Claude Code