Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,35 @@ 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 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: 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:
while offset < len(payload):
try:
offset += os.write(fd, payload[offset:])
except InterruptedError:
continue
return
except OSError:
pass

sys.stdout.buffer.write(payload)
sys.stdout.buffer.flush()


Comment on lines +154 to 167
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
def _get_mine_dir(transcript_path: str = "") -> str:
Expand Down Expand Up @@ -209,10 +236,28 @@ 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
silent_guard = False
try:
from .config import MempalaceConfig
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

# Count human messages
exchange_count = _count_human_messages(transcript_path)
Expand Down
63 changes: 63 additions & 0 deletions tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import contextlib
import io
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -218,6 +219,68 @@ 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")
Expand Down