From 914945637cc38d331910f26a7744ea4a42fbb9f6 Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 18 Apr 2026 14:37:24 -0700 Subject: [PATCH 1/7] fix(hooks): honor silent_save when stop_hook_active is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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) --- mempalace/hooks_cli.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 97832dc..61e5a9c 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -209,10 +209,19 @@ def hook_stop(data: dict, harness: str): stop_hook_active = parsed["stop_hook_active"] transcript_path = parsed["transcript_path"] - # If already in a save cycle, let through (infinite-loop prevention) + # If already in a block-mode save cycle, let through (infinite-loop prevention). + # Silent mode saves directly without returning {"decision":"block"}, so there's + # no loop to prevent — and Claude Code's plugin dispatch sets this flag on every + # fire after the first, which would otherwise suppress all subsequent auto-saves. if str(stop_hook_active).lower() in ("true", "1", "yes"): - _output({}) - return + try: + from .config import MempalaceConfig + silent_guard = MempalaceConfig().hook_silent_save + except Exception: + silent_guard = True + if not silent_guard: + _output({}) + return # Count human messages exchange_count = _count_human_messages(transcript_path) From 6a3a5c7a3d5ea305ff725d49ceab515e8ba46c9e Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 18 Apr 2026 14:45:19 -0700 Subject: [PATCH 2/7] fix(hooks): write hook JSON to real stdout, bypassing mcp_server redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- mempalace/hooks_cli.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 61e5a9c..c20a6c6 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -134,8 +134,24 @@ def _log(message: str): def _output(data: dict): - """Print JSON to stdout with consistent formatting (pretty-printed).""" - print(json.dumps(data, indent=2, ensure_ascii=False)) + """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) + sys.stdout.flush() def _get_mine_dir(transcript_path: str = "") -> str: From 5deb815f0bffd621e602459e3e0a445bff142aa0 Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 18 Apr 2026 15:48:19 -0700 Subject: [PATCH 3/7] fix(hooks): address Copilot review feedback on #1021 - _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 --- mempalace/hooks_cli.py | 50 ++++++++++++++++++++++------------ tests/test_hooks_cli.py | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index c20a6c6..e927d9e 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -134,24 +134,35 @@ def _log(message: str): def _output(data: dict): - """Print JSON to the real stdout, even if mcp_server has hijacked sys.stdout. + """Print JSON to stdout without importing modules that may redirect streams. - 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. + 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" + payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + real_stdout_fd: int | None = None + mcp_mod = sys.modules.get("mempalace.mcp_server") or sys.modules.get( + f"{__package__}.mcp_server" if __package__ else "mcp_server" + ) + if mcp_mod is not None: + real_stdout_fd = getattr(mcp_mod, "_REAL_STDOUT_FD", None) + + fd = real_stdout_fd if real_stdout_fd is not None else 1 + offset = 0 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: + while offset < len(payload): + try: + offset += os.write(fd, payload[offset:]) + except InterruptedError: + continue + return + except OSError: pass - sys.stdout.write(payload) - sys.stdout.flush() + + sys.stdout.buffer.write(payload) + sys.stdout.buffer.flush() def _get_mine_dir(transcript_path: str = "") -> str: @@ -230,11 +241,16 @@ def hook_stop(data: dict, harness: str): # no loop to prevent — and Claude Code's plugin dispatch sets this flag on every # fire after the first, which would otherwise suppress all subsequent auto-saves. if str(stop_hook_active).lower() in ("true", "1", "yes"): + silent_guard = False try: from .config import MempalaceConfig - silent_guard = MempalaceConfig().hook_silent_save - except Exception: - silent_guard = True + except ImportError as exc: + _log(f"WARNING: could not import MempalaceConfig for stop guard: {exc}; preserving block-mode guard") + else: + try: + silent_guard = MempalaceConfig().hook_silent_save + except AttributeError as exc: + _log(f"WARNING: could not read hook_silent_save: {exc}; preserving block-mode guard") if not silent_guard: _output({}) return diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index 7bf6cf3..93f6d36 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -218,6 +218,66 @@ def test_precompact_allows(tmp_path): # --- _log --- +def test_output_writes_to_real_stdout_fd_when_mcp_server_loaded(): + """_output() must reach fd 1 even when mcp_server has redirected sys.stdout.""" + import types + + fake_module = types.ModuleType("mempalace.mcp_server") + + read_fd, write_fd = os.pipe() + try: + fake_module._REAL_STDOUT_FD = write_fd + with patch.dict("sys.modules", {"mempalace.mcp_server": fake_module}): + from mempalace.hooks_cli import _output + + _output({"systemMessage": "test"}) + + os.close(write_fd) + written = b"" + while True: + chunk = os.read(read_fd, 4096) + if not chunk: + break + written += chunk + finally: + os.close(read_fd) + + data = json.loads(written.decode()) + assert data["systemMessage"] == "test" + + +def test_output_falls_back_to_fd1_when_mcp_server_absent(): + """_output() writes to fd 1 directly when mcp_server is not loaded.""" + read_fd, write_fd = os.pipe() + try: + orig_fd1 = os.dup(1) + os.dup2(write_fd, 1) + os.close(write_fd) + try: + modules_without_mcp = {k: v for k, v in __import__("sys").modules.items() + if "mcp_server" not in k} + with patch.dict("sys.modules", modules_without_mcp, clear=True): + from mempalace.hooks_cli import _output + _output({"continue": True}) + finally: + os.dup2(orig_fd1, 1) + os.close(orig_fd1) + except Exception: + os.close(read_fd) + raise + + written = b"" + while True: + chunk = os.read(read_fd, 4096) + if not chunk: + break + written += chunk + os.close(read_fd) + + data = json.loads(written.decode()) + assert data["continue"] is True + + def test_log_writes_to_hook_log(tmp_path): with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): _log("test message") From 1531a253beeeb1dc01c78a96162833a4931d13cc Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 18 Apr 2026 15:50:01 -0700 Subject: [PATCH 4/7] test: add missing import os in test_hooks_cli Co-Authored-By: Claude Sonnet 4.6 --- tests/test_hooks_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index 93f6d36..58e7d4e 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -1,6 +1,7 @@ import contextlib import io import json +import os import subprocess from pathlib import Path from unittest.mock import patch From 2183d866f3477c620b16cc34155cd5c5cf288c60 Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 18 Apr 2026 18:09:26 -0700 Subject: [PATCH 5/7] style(hooks): ruff format hooks_cli.py and test_hooks_cli.py Co-Authored-By: Claude Sonnet 4.6 --- mempalace/hooks_cli.py | 8 ++++++-- tests/test_hooks_cli.py | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index e927d9e..8532632 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -245,12 +245,16 @@ def hook_stop(data: dict, harness: str): try: from .config import MempalaceConfig except ImportError as exc: - _log(f"WARNING: could not import MempalaceConfig for stop guard: {exc}; preserving block-mode guard") + _log( + f"WARNING: could not import MempalaceConfig for stop guard: {exc}; preserving block-mode guard" + ) else: try: silent_guard = MempalaceConfig().hook_silent_save except AttributeError as exc: - _log(f"WARNING: could not read hook_silent_save: {exc}; preserving block-mode guard") + _log( + f"WARNING: could not read hook_silent_save: {exc}; preserving block-mode guard" + ) if not silent_guard: _output({}) return diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index 58e7d4e..8e54837 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -255,10 +255,12 @@ def test_output_falls_back_to_fd1_when_mcp_server_absent(): os.dup2(write_fd, 1) os.close(write_fd) try: - modules_without_mcp = {k: v for k, v in __import__("sys").modules.items() - if "mcp_server" not in k} + modules_without_mcp = { + k: v for k, v in __import__("sys").modules.items() if "mcp_server" not in k + } with patch.dict("sys.modules", modules_without_mcp, clear=True): from mempalace.hooks_cli import _output + _output({"continue": True}) finally: os.dup2(orig_fd1, 1) From 2629ae5b713a0da6bb2e5b5f7c0f0d9b93e89238 Mon Sep 17 00:00:00 2001 From: jp Date: Sun, 19 Apr 2026 08:22:45 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(hooks):=20default=20silent=5Fguard=3DTr?= =?UTF-8?q?ue=20=E2=80=94=20config-read=20failure=20must=20not=20suppress?= =?UTF-8?q?=20saves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses bensig's review on PR #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) --- mempalace/hooks_cli.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 8532632..9ee0661 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -241,19 +241,22 @@ def hook_stop(data: dict, harness: str): # no loop to prevent — and Claude Code's plugin dispatch sets this flag on every # fire after the first, which would otherwise suppress all subsequent auto-saves. if str(stop_hook_active).lower() in ("true", "1", "yes"): - silent_guard = False + # Safe default: assume silent mode on any config-read failure so saves + # proceed rather than being silently dropped. Silent mode is the default + # (v3.3.0+), so if we can't read config, behave as if it's still on. + silent_guard = True try: from .config import MempalaceConfig except ImportError as exc: _log( - f"WARNING: could not import MempalaceConfig for stop guard: {exc}; preserving block-mode guard" + f"WARNING: could not import MempalaceConfig for stop guard: {exc}; defaulting to silent mode" ) else: try: silent_guard = MempalaceConfig().hook_silent_save except AttributeError as exc: _log( - f"WARNING: could not read hook_silent_save: {exc}; preserving block-mode guard" + f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode" ) if not silent_guard: _output({}) From d657626736e74efba0008b11dee085fd89ea00de Mon Sep 17 00:00:00 2001 From: jp Date: Sun, 19 Apr 2026 08:34:43 -0700 Subject: [PATCH 7/7] =?UTF-8?q?style:=20ruff=20format=20=E2=80=94=20collap?= =?UTF-8?q?se=20AttributeError=20log=20call=20to=20single=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mempalace/hooks_cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index 9ee0661..c6e4a8d 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -255,9 +255,7 @@ def hook_stop(data: dict, harness: str): try: silent_guard = MempalaceConfig().hook_silent_save except AttributeError as exc: - _log( - f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode" - ) + _log(f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode") if not silent_guard: _output({}) return