Skip to content

Commit 02aafc0

Browse files
authored
Merge pull request #1021 from jphein/upstream-fix/silent-save-visibility
silent_guard default fixed to True per 2026-04-19 review. ImportError/AttributeError handling narrowed. CI all green.
2 parents 810f9a5 + d657626 commit 02aafc0

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

mempalace/hooks_cli.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,35 @@ def _log(message: str):
134134

135135

136136
def _output(data: dict):
137-
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
138-
print(json.dumps(data, indent=2, ensure_ascii=False))
137+
"""Print JSON to stdout without importing modules that may redirect streams.
138+
139+
If mempalace.mcp_server is already loaded, reuse its saved real stdout fd.
140+
Otherwise, write directly to fd 1 so hook responses still go to stdout even
141+
if sys.stdout has been redirected elsewhere.
142+
"""
143+
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
144+
145+
real_stdout_fd: int | None = None
146+
mcp_mod = sys.modules.get("mempalace.mcp_server") or sys.modules.get(
147+
f"{__package__}.mcp_server" if __package__ else "mcp_server"
148+
)
149+
if mcp_mod is not None:
150+
real_stdout_fd = getattr(mcp_mod, "_REAL_STDOUT_FD", None)
151+
152+
fd = real_stdout_fd if real_stdout_fd is not None else 1
153+
offset = 0
154+
try:
155+
while offset < len(payload):
156+
try:
157+
offset += os.write(fd, payload[offset:])
158+
except InterruptedError:
159+
continue
160+
return
161+
except OSError:
162+
pass
163+
164+
sys.stdout.buffer.write(payload)
165+
sys.stdout.buffer.flush()
139166

140167

141168
def _get_mine_dir(transcript_path: str = "") -> str:
@@ -259,10 +286,29 @@ def hook_stop(data: dict, harness: str):
259286
stop_hook_active = parsed["stop_hook_active"]
260287
transcript_path = parsed["transcript_path"]
261288

262-
# If already in a save cycle, let through (infinite-loop prevention)
289+
# If already in a block-mode save cycle, let through (infinite-loop prevention).
290+
# Silent mode saves directly without returning {"decision":"block"}, so there's
291+
# no loop to prevent — and Claude Code's plugin dispatch sets this flag on every
292+
# fire after the first, which would otherwise suppress all subsequent auto-saves.
263293
if str(stop_hook_active).lower() in ("true", "1", "yes"):
264-
_output({})
265-
return
294+
# Safe default: assume silent mode on any config-read failure so saves
295+
# proceed rather than being silently dropped. Silent mode is the default
296+
# (v3.3.0+), so if we can't read config, behave as if it's still on.
297+
silent_guard = True
298+
try:
299+
from .config import MempalaceConfig
300+
except ImportError as exc:
301+
_log(
302+
f"WARNING: could not import MempalaceConfig for stop guard: {exc}; defaulting to silent mode"
303+
)
304+
else:
305+
try:
306+
silent_guard = MempalaceConfig().hook_silent_save
307+
except AttributeError as exc:
308+
_log(f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode")
309+
if not silent_guard:
310+
_output({})
311+
return
266312

267313
# Count human messages
268314
exchange_count = _count_human_messages(transcript_path)

tests/test_hooks_cli.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,68 @@ def test_precompact_allows(tmp_path):
220220
# --- _log ---
221221

222222

223+
def test_output_writes_to_real_stdout_fd_when_mcp_server_loaded():
224+
"""_output() must reach fd 1 even when mcp_server has redirected sys.stdout."""
225+
import types
226+
227+
fake_module = types.ModuleType("mempalace.mcp_server")
228+
229+
read_fd, write_fd = os.pipe()
230+
try:
231+
fake_module._REAL_STDOUT_FD = write_fd
232+
with patch.dict("sys.modules", {"mempalace.mcp_server": fake_module}):
233+
from mempalace.hooks_cli import _output
234+
235+
_output({"systemMessage": "test"})
236+
237+
os.close(write_fd)
238+
written = b""
239+
while True:
240+
chunk = os.read(read_fd, 4096)
241+
if not chunk:
242+
break
243+
written += chunk
244+
finally:
245+
os.close(read_fd)
246+
247+
data = json.loads(written.decode())
248+
assert data["systemMessage"] == "test"
249+
250+
251+
def test_output_falls_back_to_fd1_when_mcp_server_absent():
252+
"""_output() writes to fd 1 directly when mcp_server is not loaded."""
253+
read_fd, write_fd = os.pipe()
254+
try:
255+
orig_fd1 = os.dup(1)
256+
os.dup2(write_fd, 1)
257+
os.close(write_fd)
258+
try:
259+
modules_without_mcp = {
260+
k: v for k, v in __import__("sys").modules.items() if "mcp_server" not in k
261+
}
262+
with patch.dict("sys.modules", modules_without_mcp, clear=True):
263+
from mempalace.hooks_cli import _output
264+
265+
_output({"continue": True})
266+
finally:
267+
os.dup2(orig_fd1, 1)
268+
os.close(orig_fd1)
269+
except Exception:
270+
os.close(read_fd)
271+
raise
272+
273+
written = b""
274+
while True:
275+
chunk = os.read(read_fd, 4096)
276+
if not chunk:
277+
break
278+
written += chunk
279+
os.close(read_fd)
280+
281+
data = json.loads(written.decode())
282+
assert data["continue"] is True
283+
284+
223285
def test_log_writes_to_hook_log(tmp_path):
224286
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
225287
_log("test message")

0 commit comments

Comments
 (0)