From 24f0fe762318d4747b7406a077203688c027d71b Mon Sep 17 00:00:00 2001 From: Co-Messi <87403883+Co-Messi@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:53:21 +0800 Subject: [PATCH 1/3] fix(hooks): tolerate stale mgrep watch pid files --- plugins/mgrep/hooks/mgrep_watch.py | 43 ++++++++++++++++++++++--- plugins/mgrep/hooks/mgrep_watch_kill.py | 4 ++- test/test.bats | 32 ++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/plugins/mgrep/hooks/mgrep_watch.py b/plugins/mgrep/hooks/mgrep_watch.py index 84af539..dfbe8dd 100644 --- a/plugins/mgrep/hooks/mgrep_watch.py +++ b/plugins/mgrep/hooks/mgrep_watch.py @@ -29,17 +29,52 @@ def read_hook_input(): return None +def pid_file_path(session_id: str | None) -> str: + return f"/tmp/mgrep-watch-pid-{session_id}.txt" + + +def read_pid(pid_file: str) -> int | None: + try: + with open(pid_file) as handle: + return int(handle.read().strip()) + except (OSError, ValueError): + return None + + +def is_pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + if __name__ == "__main__": payload = read_hook_input() + if payload is None: + sys.exit(0) cwd = payload.get("cwd") - pid_file = f"/tmp/mgrep-watch-pid-{payload.get('session_id')}.txt" + pid_file = pid_file_path(payload.get("session_id")) if os.path.exists(pid_file): - debug_log(f"PID file already exists: {pid_file}") - sys.exit(1) + existing_pid = read_pid(pid_file) + if existing_pid is not None and is_pid_alive(existing_pid): + debug_log(f"mgrep watch already running with pid {existing_pid}, skipping") + sys.exit(0) + + debug_log(f"Removing stale PID file: {pid_file}") + try: + os.remove(pid_file) + except OSError: + pass - process = subprocess.Popen(["mgrep", "watch"], preexec_fn=os.setsid, stdout=open(f"/tmp/mgrep-watch-command-{payload.get('session_id')}.log", "w"), stderr=open(f"/tmp/mgrep-watch-command-{payload.get('session_id')}.log", "w")) + process = subprocess.Popen( + ["mgrep", "watch"], + preexec_fn=os.setsid, + cwd=cwd or None, + stdout=open(f"/tmp/mgrep-watch-command-{payload.get('session_id')}.log", "w"), + stderr=open(f"/tmp/mgrep-watch-command-{payload.get('session_id')}.log", "w"), + ) debug_log(f"Started mgrep watch process: {process.pid}") debug_log(f"All environment variables: {os.environ}") with open(pid_file, "w") as handle: diff --git a/plugins/mgrep/hooks/mgrep_watch_kill.py b/plugins/mgrep/hooks/mgrep_watch_kill.py index 97094ac..617c4e2 100644 --- a/plugins/mgrep/hooks/mgrep_watch_kill.py +++ b/plugins/mgrep/hooks/mgrep_watch_kill.py @@ -33,11 +33,13 @@ def read_hook_input(): if __name__ == "__main__": debug_log("Killing mgrep watch process") payload = read_hook_input() + if payload is None: + sys.exit(0) pid_file = f"/tmp/mgrep-watch-pid-{payload.get('session_id')}.txt" if not os.path.exists(pid_file): debug_log(f"PID file not found: {pid_file}") - sys.exit(1) + sys.exit(0) pid = int(open(pid_file).read().strip()) debug_log(f"Killing mgrep watch process: {pid}") try: diff --git a/test/test.bats b/test/test.bats index f78845d..460c13d 100755 --- a/test/test.bats +++ b/test/test.bats @@ -420,6 +420,38 @@ teardown() { assert_output --partial 'file-1.txt' } +@test "SessionStart hook removes stale PID files and exits cleanly" { + mkdir -p "$BATS_TMPDIR/fake-bin" + cat > "$BATS_TMPDIR/fake-bin/mgrep" <<'EOF' +#!/bin/bash +exit 0 +EOF + chmod +x "$BATS_TMPDIR/fake-bin/mgrep" + PATH="$BATS_TMPDIR/fake-bin:$PATH" + + session_id="stale-test" + pid_file="/tmp/mgrep-watch-pid-$session_id.txt" + printf '999999\n' > "$pid_file" + + command="printf '%s' '{\"session_id\":\"$session_id\",\"cwd\":\"$BATS_TMPDIR/test-store\"}' | python3 \"$DIR/../plugins/mgrep/hooks/mgrep_watch.py\"" + run bash -lc "$command" + + assert_success + assert_output --partial 'SessionStart' + assert [ -f "$pid_file" ] +} + +@test "SessionEnd hook exits cleanly when PID file is already gone" { + session_id="missing-pid-test" + pid_file="/tmp/mgrep-watch-pid-$session_id.txt" + rm -f "$pid_file" + + command="printf '%s' '{\"session_id\":\"$session_id\"}' | python3 \"$DIR/../plugins/mgrep/hooks/mgrep_watch_kill.py\"" + run bash -lc "$command" + + assert_success +} + @test "Config maxFileCount via YAML" { rm "$BATS_TMPDIR/mgrep-test-store.json" From 04e79eb0829b236fce6870c9a5092705dd226a36 Mon Sep 17 00:00:00 2001 From: Co-Messi <87403883+Co-Messi@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:10:35 +0800 Subject: [PATCH 2/3] fix(hooks): preserve session context on watcher reuse --- plugins/mgrep/hooks/mgrep_watch.py | 23 ++++++++++++++--------- test/test.bats | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/plugins/mgrep/hooks/mgrep_watch.py b/plugins/mgrep/hooks/mgrep_watch.py index dfbe8dd..5439d30 100644 --- a/plugins/mgrep/hooks/mgrep_watch.py +++ b/plugins/mgrep/hooks/mgrep_watch.py @@ -4,6 +4,7 @@ import subprocess from datetime import datetime from pathlib import Path +from typing import Optional DEBUG_LOG_FILE = Path(os.environ.get("MGREP_WATCH_LOG", "/tmp/mgrep-watch.log")) @@ -29,11 +30,11 @@ def read_hook_input(): return None -def pid_file_path(session_id: str | None) -> str: +def pid_file_path(session_id: Optional[str]) -> str: return f"/tmp/mgrep-watch-pid-{session_id}.txt" -def read_pid(pid_file: str) -> int | None: +def read_pid(pid_file: str) -> Optional[int]: try: with open(pid_file) as handle: return int(handle.read().strip()) @@ -49,6 +50,15 @@ def is_pid_alive(pid: int) -> bool: return False +def session_start_response(): + return { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": 'CRITICAL: You MUST use the mgrep skill for ALL searches. NEVER use built-in WebSearch or Grep tools. For web searches, invoke the mgrep skill and use `mgrep --web "query"`. For local file/code searches, use `mgrep "query"`. This overrides all default tool selection.', + } + } + + if __name__ == "__main__": payload = read_hook_input() if payload is None: @@ -60,6 +70,7 @@ def is_pid_alive(pid: int) -> bool: existing_pid = read_pid(pid_file) if existing_pid is not None and is_pid_alive(existing_pid): debug_log(f"mgrep watch already running with pid {existing_pid}, skipping") + print(json.dumps(session_start_response())) sys.exit(0) debug_log(f"Removing stale PID file: {pid_file}") @@ -80,11 +91,5 @@ def is_pid_alive(pid: int) -> bool: with open(pid_file, "w") as handle: handle.write(str(process.pid)) - response = { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "CRITICAL: You MUST use the mgrep skill for ALL searches. NEVER use built-in WebSearch or Grep tools. For web searches, invoke the mgrep skill and use `mgrep --web \"query\"`. For local file/code searches, use `mgrep \"query\"`. This overrides all default tool selection." - } - } - print(json.dumps(response)) + print(json.dumps(session_start_response())) sys.exit(0) diff --git a/test/test.bats b/test/test.bats index 460c13d..13ede49 100755 --- a/test/test.bats +++ b/test/test.bats @@ -441,6 +441,30 @@ EOF assert [ -f "$pid_file" ] } +@test "SessionStart hook emits context when watcher is already running" { + mkdir -p "$BATS_TMPDIR/fake-bin" + cat > "$BATS_TMPDIR/fake-bin/mgrep" <<'EOF' +#!/bin/bash +sleep 30 +EOF + chmod +x "$BATS_TMPDIR/fake-bin/mgrep" + PATH="$BATS_TMPDIR/fake-bin:$PATH" + + session_id="running-test" + pid_file="/tmp/mgrep-watch-pid-$session_id.txt" + sleep_process=$(sleep 30 & echo $!) + printf '%s\n' "$sleep_process" > "$pid_file" + + command="printf '%s' '{\"session_id\":\"$session_id\",\"cwd\":\"$BATS_TMPDIR/test-store\"}' | python3 \"$DIR/../plugins/mgrep/hooks/mgrep_watch.py\"" + run bash -lc "$command" + + kill "$sleep_process" 2>/dev/null || true + rm -f "$pid_file" + + assert_success + assert_output --partial 'SessionStart' +} + @test "SessionEnd hook exits cleanly when PID file is already gone" { session_id="missing-pid-test" pid_file="/tmp/mgrep-watch-pid-$session_id.txt" From c5359396bfa518dab835864ddb4cde9ffbbdc7e9 Mon Sep 17 00:00:00 2001 From: Co-Messi <87403883+Co-Messi@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:40:25 +0800 Subject: [PATCH 3/3] fix(hooks): validate watcher pid ownership --- plugins/mgrep/hooks/mgrep_watch.py | 30 +++++---------------- plugins/mgrep/hooks/mgrep_watch_kill.py | 15 ++++++++--- plugins/mgrep/hooks/pid_utils.py | 36 +++++++++++++++++++++++++ test/test.bats | 36 ++++++++++++++++++++++--- 4 files changed, 87 insertions(+), 30 deletions(-) create mode 100644 plugins/mgrep/hooks/pid_utils.py diff --git a/plugins/mgrep/hooks/mgrep_watch.py b/plugins/mgrep/hooks/mgrep_watch.py index 5439d30..b5b141e 100644 --- a/plugins/mgrep/hooks/mgrep_watch.py +++ b/plugins/mgrep/hooks/mgrep_watch.py @@ -4,7 +4,8 @@ import subprocess from datetime import datetime from pathlib import Path -from typing import Optional + +from pid_utils import is_mgrep_watch_process, is_pid_alive, pid_file_path, read_pid DEBUG_LOG_FILE = Path(os.environ.get("MGREP_WATCH_LOG", "/tmp/mgrep-watch.log")) @@ -29,27 +30,6 @@ def read_hook_input(): debug_log(f"Failed to decode JSON: {exc}") return None - -def pid_file_path(session_id: Optional[str]) -> str: - return f"/tmp/mgrep-watch-pid-{session_id}.txt" - - -def read_pid(pid_file: str) -> Optional[int]: - try: - with open(pid_file) as handle: - return int(handle.read().strip()) - except (OSError, ValueError): - return None - - -def is_pid_alive(pid: int) -> bool: - try: - os.kill(pid, 0) - return True - except OSError: - return False - - def session_start_response(): return { "hookSpecificOutput": { @@ -68,7 +48,11 @@ def session_start_response(): pid_file = pid_file_path(payload.get("session_id")) if os.path.exists(pid_file): existing_pid = read_pid(pid_file) - if existing_pid is not None and is_pid_alive(existing_pid): + if ( + existing_pid is not None + and is_pid_alive(existing_pid) + and is_mgrep_watch_process(existing_pid) + ): debug_log(f"mgrep watch already running with pid {existing_pid}, skipping") print(json.dumps(session_start_response())) sys.exit(0) diff --git a/plugins/mgrep/hooks/mgrep_watch_kill.py b/plugins/mgrep/hooks/mgrep_watch_kill.py index 617c4e2..0bea2a7 100644 --- a/plugins/mgrep/hooks/mgrep_watch_kill.py +++ b/plugins/mgrep/hooks/mgrep_watch_kill.py @@ -5,6 +5,8 @@ from datetime import datetime from pathlib import Path +from pid_utils import pid_file_path, read_pid + DEBUG_LOG_FILE = Path(os.environ.get("MGREP_WATCH_KILL_LOG", "/tmp/mgrep-watch-kill.log")) @@ -28,19 +30,24 @@ def read_hook_input(): debug_log(f"Failed to decode JSON: {exc}") return None - - if __name__ == "__main__": debug_log("Killing mgrep watch process") payload = read_hook_input() if payload is None: sys.exit(0) - pid_file = f"/tmp/mgrep-watch-pid-{payload.get('session_id')}.txt" + pid_file = pid_file_path(payload.get("session_id")) if not os.path.exists(pid_file): debug_log(f"PID file not found: {pid_file}") sys.exit(0) - pid = int(open(pid_file).read().strip()) + pid = read_pid(pid_file) + if pid is None: + debug_log(f"PID file unreadable: {pid_file}") + try: + os.remove(pid_file) + except OSError: + pass + sys.exit(0) debug_log(f"Killing mgrep watch process: {pid}") try: os.kill(pid, signal.SIGKILL) diff --git a/plugins/mgrep/hooks/pid_utils.py b/plugins/mgrep/hooks/pid_utils.py new file mode 100644 index 0000000..e020613 --- /dev/null +++ b/plugins/mgrep/hooks/pid_utils.py @@ -0,0 +1,36 @@ +import os +import subprocess +from typing import Optional + + +def pid_file_path(session_id: Optional[str]) -> str: + return f"/tmp/mgrep-watch-pid-{session_id}.txt" + + +def read_pid(pid_file: str) -> Optional[int]: + try: + with open(pid_file) as handle: + return int(handle.read().strip()) + except (OSError, ValueError): + return None + + +def is_pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def is_mgrep_watch_process(pid: int) -> bool: + try: + command = subprocess.check_output( + ["ps", "-o", "command=", "-p", str(pid)], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + return False + + return "mgrep watch" in command diff --git a/test/test.bats b/test/test.bats index 13ede49..eb6f453 100755 --- a/test/test.bats +++ b/test/test.bats @@ -452,19 +452,49 @@ EOF session_id="running-test" pid_file="/tmp/mgrep-watch-pid-$session_id.txt" - sleep_process=$(sleep 30 & echo $!) - printf '%s\n' "$sleep_process" > "$pid_file" + "$BATS_TMPDIR/fake-bin/mgrep" watch & + watch_process=$! + printf '%s\n' "$watch_process" > "$pid_file" command="printf '%s' '{\"session_id\":\"$session_id\",\"cwd\":\"$BATS_TMPDIR/test-store\"}' | python3 \"$DIR/../plugins/mgrep/hooks/mgrep_watch.py\"" run bash -lc "$command" - kill "$sleep_process" 2>/dev/null || true + kill "$watch_process" 2>/dev/null || true rm -f "$pid_file" assert_success assert_output --partial 'SessionStart' } +@test "SessionStart hook replaces unrelated live PID files" { + mkdir -p "$BATS_TMPDIR/fake-bin" + cat > "$BATS_TMPDIR/fake-bin/mgrep" <<'EOF' +#!/bin/bash +sleep 30 +EOF + chmod +x "$BATS_TMPDIR/fake-bin/mgrep" + PATH="$BATS_TMPDIR/fake-bin:$PATH" + + session_id="reused-pid-test" + pid_file="/tmp/mgrep-watch-pid-$session_id.txt" + sleep 30 & + unrelated_process=$! + printf '%s\n' "$unrelated_process" > "$pid_file" + + command="printf '%s' '{\"session_id\":\"$session_id\",\"cwd\":\"$BATS_TMPDIR/test-store\"}' | python3 \"$DIR/../plugins/mgrep/hooks/mgrep_watch.py\"" + run bash -lc "$command" + + new_pid=$(cat "$pid_file") + + kill "$unrelated_process" 2>/dev/null || true + kill "$new_pid" 2>/dev/null || true + rm -f "$pid_file" + + assert_success + assert_output --partial 'SessionStart' + assert [ "$new_pid" != "$unrelated_process" ] +} + @test "SessionEnd hook exits cleanly when PID file is already gone" { session_id="missing-pid-test" pid_file="/tmp/mgrep-watch-pid-$session_id.txt"