Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
46 changes: 35 additions & 11 deletions plugins/mgrep/hooks/mgrep_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from datetime import datetime
from pathlib import Path

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"))


Expand All @@ -28,28 +30,50 @@ def read_hook_input():
debug_log(f"Failed to decode JSON: {exc}")
return None

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:
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)
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)
Comment thread
cursor[bot] marked this conversation as resolved.

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"))
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,
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:
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)
19 changes: 14 additions & 5 deletions plugins/mgrep/hooks/mgrep_watch_kill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))


Expand All @@ -28,17 +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(1)
pid = int(open(pid_file).read().strip())
sys.exit(0)
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)
Expand Down
36 changes: 36 additions & 0 deletions plugins/mgrep/hooks/pid_utils.py
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions test/test.bats
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,92 @@ 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 "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"
"$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 "$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"
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"

Expand Down
Loading