diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 861b16d..19db408 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -80,6 +80,15 @@ "author": { "name": "JacobPEvans" } + }, + { + "name": "process-cleanup", + "source": "./process-cleanup", + "description": "Cleanup orphaned MCP server processes on session exit — workaround for upstream bug #1935", + "version": "1.0.0", + "author": { + "name": "JacobPEvans" + } } ] } diff --git a/process-cleanup/.claude-plugin/plugin.json b/process-cleanup/.claude-plugin/plugin.json new file mode 100644 index 0000000..7668258 --- /dev/null +++ b/process-cleanup/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "process-cleanup", + "version": "1.0.0", + "description": "Cleanup orphaned MCP server processes on session exit — workaround for upstream bug #1935", + "author": { "name": "JacobPEvans" }, + "license": "Apache-2.0", + "repository": "https://github.com/JacobPEvans/claude-code-plugins", + "keywords": ["hooks", "cleanup", "mcp", "orphan", "process", "stop"] +} diff --git a/process-cleanup/README.md b/process-cleanup/README.md new file mode 100644 index 0000000..31405b1 --- /dev/null +++ b/process-cleanup/README.md @@ -0,0 +1,44 @@ +# process-cleanup + +Cleans up orphaned MCP server processes when a Claude Code session exits. + +## Purpose + +Workaround for upstream Claude Code bug [#1935](https://github.com/anthropics/claude-code/issues/1935), +where MCP server child processes are not reliably terminated when a Claude session ends. + +This plugin fires on the `Stop` event (triggered by `/exit` or Ctrl+C) and sweeps +system-wide for orphaned processes with `ppid=1` — meaning they were reparented to +launchd because their parent terminal died without cleanup. + +## Safety Guarantees + +- **Only kills ppid=1 processes** — cannot affect processes with a living parent +- **Cannot affect other Claude sessions** — active sessions have a living parent terminal +- **SIGTERM first, SIGKILL only for survivors** — 2-second grace period +- **Targets node processes by substring match** — matches orphaned `node` processes whose command + line contains `mcp` or `context7`; unrelated node processes with those substrings could be affected + +## Targets + +| Process | Description | +|---------|-------------| +| `terraform-mcp-server` | Terraform/OpenTofu MCP server | +| `context7-mcp` | Context7 documentation MCP server | +| `node` (with MCP args) | Node-based MCP servers with mcp/context7 in arguments | + +## Logs + +Cleanup activity is logged to: + +```text +~/Library/Logs/claude-process-cleanup/cleanup-YYYY-MM-DD.log +``` + +## Hook Architecture + +This is a **defense-in-depth** second layer. The primary cleanup runs via `zshexit()` +in the parent zsh shell (Layer 1), which catches tab close via SIGHUP. This layer +catches explicit Claude exits (`/exit`, Ctrl+C) when zshexit may not fire. + +Both layers are complementary and safe to run together. diff --git a/process-cleanup/hooks/hooks.json b/process-cleanup/hooks/hooks.json new file mode 100644 index 0000000..0be3b12 --- /dev/null +++ b/process-cleanup/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-cleanup.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/process-cleanup/scripts/session-cleanup.sh b/process-cleanup/scripts/session-cleanup.sh new file mode 100755 index 0000000..d4b1aa3 --- /dev/null +++ b/process-cleanup/scripts/session-cleanup.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Claude Code Stop Hook: Cleanup orphaned MCP processes +# +# Fires when a Claude session exits via /exit or Ctrl+C. +# Sweeps for orphaned MCP server processes system-wide (ppid=1 means the +# process was reparented to launchd — its parent terminal died without cleanup). +# +# SAFETY: Only kills processes with ppid=1. Never touches processes that have +# a living parent. Cannot affect active Claude sessions in other terminals. +# +# Targets: +# - terraform-mcp-server (Terraform MCP server) +# - context7-mcp (Context7 MCP server, may run as node) +# - node processes with MCP-related arguments (ppid=1) +# +# Logs to: ~/Library/Logs/claude-process-cleanup/ + +set -euo pipefail + +LOG_DIR="$HOME/Library/Logs/claude-process-cleanup" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/cleanup-$(date +%Y-%m-%d).log" + +log_info() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*" >> "$LOG_FILE" +} + +log_warn() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] $*" >> "$LOG_FILE" +} + +# Find orphaned processes where ppid=1 and command path matches a pattern +find_orphans_by_pattern() { + local pattern=$1 + ps -Aeo pid,ppid,command | awk -v pat="$pattern" '$2 == 1 && $3 ~ pat {print $1}' +} + +# Find orphaned node processes running MCP servers (ppid=1, args reference mcp/context7) +find_orphan_node_mcp() { + ps -Aeo pid,ppid,command | awk '$2 == 1 && $3 ~ /node/ && ($0 ~ /mcp|context7/) {print $1}' +} + +declare -a mcp_patterns=( + "terraform-mcp" + "context7-mcp" +) + +declare -A seen_pids=() +declare -a all_pids=() + +# Collect orphans by process name pattern (deduplicate via seen_pids) +for pattern in "${mcp_patterns[@]}"; do + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + [[ -n "${seen_pids[$pid]:-}" ]] && continue + seen_pids[$pid]=1 + all_pids+=("$pid") + log_info "Found orphaned ${pattern} (pid=${pid}, ppid=1)" + done < <(find_orphans_by_pattern "$pattern") +done + +# Collect orphaned node MCP processes (deduplicate) +while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + [[ -n "${seen_pids[$pid]:-}" ]] && continue + seen_pids[$pid]=1 + all_pids+=("$pid") + log_info "Found orphaned node MCP process (pid=${pid}, ppid=1)" +done < <(find_orphan_node_mcp) + +[[ ${#all_pids[@]} -eq 0 ]] && exit 0 + +total_killed=0 + +# SIGTERM (graceful shutdown) +for pid in "${all_pids[@]}"; do + if kill -TERM "$pid" 2>/dev/null; then + total_killed=$((total_killed + 1)) + fi +done + +sleep 2 + +# SIGKILL survivors — re-validate PID identity before escalating +for pid in "${all_pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + # Confirm PID still belongs to a target process (guards against PID reuse) + pid_cmd=$(ps -p "$pid" -o command= 2>/dev/null || true) + if [[ "$pid_cmd" =~ terraform-mcp|context7-mcp|node ]]; then + log_warn "SIGKILL to surviving process (pid=${pid}, cmd=${pid_cmd})" + kill -KILL "$pid" 2>/dev/null || true + fi + fi +done + +log_info "Cleanup complete: sent SIGTERM to ${total_killed} orphaned MCP process(es)" + +exit 0