From 970ec5bb90361383ed666c8c545b9e73b0f55093 Mon Sep 17 00:00:00 2001 From: Kenta Mori Date: Thu, 18 Jun 2026 10:59:59 +0900 Subject: [PATCH] fix: replace ps aux UUID grep with session registry for liveness detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ps aux approach fails for in-process backend (Agent Teams default) because session UUIDs don't appear on the process command line. Instead, read ~/.claude/sessions/.json, verify PID liveness with os.kill, and match teams by cwd + createdAt≈startedAt (5s tolerance). Also adds repo and cwd fields to scan output for both ghost and active teams. Co-Authored-By: Claude --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- bin/ghost-scan.sh | 114 ++++++++++++++++++++++++++------ 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 39f2871..5015ec6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,14 +4,14 @@ "owner": { "name": "ClaudeCodeCafe" }, "metadata": { "description": "Detect and clean ghost teams, orphaned tasks, and numbered agent duplicates from Claude Code Agent Teams.", - "version": "0.1.0" + "version": "0.2.0" }, "plugins": [ { "name": "ghostbuster", "source": "./", "description": "Scan and clean ghost teams from ~/.claude/teams/ and ~/.claude/tasks/ — orphaned configs, numbered agent duplicates, dead tmux panes, and orphan task dirs. Includes /scan and /clean commands.", - "version": "0.1.0", + "version": "0.2.0", "author": { "name": "ClaudeCodeCafe" }, "keywords": ["claude", "claude-code", "agent-teams", "cleanup", "ghost", "plugin"], "category": "tools" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5ccb036..908620c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ghostbuster", "description": "Detect and clean ghost teams, orphaned tasks, and numbered agent duplicates from ~/.claude/teams/ and ~/.claude/tasks/", - "version": "0.1.0", + "version": "0.2.0", "author": { "name": "ClaudeCodeCafe" }, "license": "MIT", "skills": ["./skills/ghostbuster"], diff --git a/bin/ghost-scan.sh b/bin/ghost-scan.sh index 89430be..2ed51bf 100755 --- a/bin/ghost-scan.sh +++ b/bin/ghost-scan.sh @@ -7,10 +7,7 @@ set -euo pipefail TEAMS_DIR="${HOME}/.claude/teams" TASKS_DIR="${HOME}/.claude/tasks" - -# Collect running claude session IDs from ps -# shellcheck disable=SC2009 # need full command line to extract session UUIDs; pgrep can't -active_sessions="$(ps aux 2>/dev/null | grep -i '[c]laude' | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | sort -u || true)" +SESSIONS_DIR="${HOME}/.claude/sessions" # Check if tmux is available and running tmux_running="false" @@ -20,28 +17,100 @@ if command -v tmux >/dev/null 2>&1 && tmux list-sessions >/dev/null 2>&1; then tmux_panes="$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null || true)" fi -# Export env vars for python3 to read -export ACTIVE_SESSIONS="$active_sessions" export TMUX_PANES="$tmux_panes" # Pass everything to python3 for analysis and JSON output -python3 - "$TEAMS_DIR" "$TASKS_DIR" "$tmux_running" <<'PYEOF' +python3 - "$TEAMS_DIR" "$TASKS_DIR" "$tmux_running" "$SESSIONS_DIR" <<'PYEOF' import sys import os import json import re +import signal teams_dir = sys.argv[1] tasks_dir = sys.argv[2] tmux_running = sys.argv[3] == "true" - -# Read active sessions and tmux panes from env -active_sessions_raw = os.environ.get("ACTIVE_SESSIONS", "") -active_sessions = set(active_sessions_raw.strip().split("\n")) if active_sessions_raw.strip() else set() +sessions_dir = sys.argv[4] tmux_panes_raw = os.environ.get("TMUX_PANES", "") tmux_panes = set(tmux_panes_raw.strip().split("\n")) if tmux_panes_raw.strip() else set() +# Build session registry from ~/.claude/sessions/.json +# Each file: { pid, sessionId, cwd, startedAt, ... } +live_sessions = [] # list of dicts with pid, sessionId, cwd, startedAt + +if os.path.isdir(sessions_dir): + for fname in os.listdir(sessions_dir): + if not fname.endswith(".json"): + continue + fpath = os.path.join(sessions_dir, fname) + try: + with open(fpath, "r") as f: + sess = json.load(f) + except (json.JSONDecodeError, IOError): + continue + + pid = sess.get("pid") + if pid is None: + continue + + # Check if PID is alive + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + # ProcessLookupError = dead, PermissionError = alive but no permission (still alive) + if isinstance(sys.exc_info()[1], ProcessLookupError): + continue + except OSError: + continue + + live_sessions.append({ + "pid": pid, + "sessionId": sess.get("sessionId", ""), + "cwd": sess.get("cwd", ""), + "startedAt": sess.get("startedAt", 0), + }) + + +def find_matching_session(team_config): + """Check if a team has a live session by matching cwd + createdAt≈startedAt (within 5s).""" + created_at = team_config.get("createdAt", 0) + members = team_config.get("members", []) + if isinstance(members, dict): + members = list(members.values()) + + team_cwds = set() + for m in members: + if isinstance(m, dict) and m.get("cwd"): + team_cwds.add(m["cwd"]) + + for sess in live_sessions: + if sess["cwd"] not in team_cwds: + continue + time_diff = abs(sess["startedAt"] - created_at) + if time_diff <= 5000: # 5 seconds in ms + return sess + return None + + +def extract_repo(cwd): + """Extract repo name from cwd path (last path component).""" + if not cwd: + return "" + return os.path.basename(cwd.rstrip("/")) + + +def get_team_cwd(config): + """Get the primary cwd from team config (first member's cwd).""" + members = config.get("members", []) + if isinstance(members, dict): + members = list(members.values()) + for m in members: + if isinstance(m, dict) and m.get("cwd"): + return m["cwd"] + return "" + + ghost_teams = [] active_teams = [] @@ -56,10 +125,11 @@ if os.path.isdir(teams_dir): config_path = os.path.join(team_path, "config.json") if not os.path.isfile(config_path): - # No config.json — ghost ghost_teams.append({ "name": entry, "path": team_path, + "repo": "", + "cwd": "", "reasons": ["no config.json"], "numbered_ghosts": [], "members": [] @@ -73,6 +143,8 @@ if os.path.isdir(teams_dir): ghost_teams.append({ "name": entry, "path": team_path, + "repo": "", + "cwd": "", "reasons": ["corrupt config.json"], "numbered_ghosts": [], "members": [] @@ -83,16 +155,17 @@ if os.path.isdir(teams_dir): numbered_ghosts = [] member_names = [] + team_cwd = get_team_cwd(config) + team_repo = extract_repo(team_cwd) + # Check for backup remnant if ".backup." in entry: reasons.append("backup remnant") - # Check leadSessionId against active sessions - lead_session = config.get("leadSessionId", "") - if lead_session and lead_session not in active_sessions: + # Check session liveness via session registry + matched_session = find_matching_session(config) + if matched_session is None: reasons.append("no active session") - elif not lead_session: - reasons.append("no leadSessionId") # Check members members = config.get("members", []) @@ -110,11 +183,9 @@ if os.path.isdir(teams_dir): if name: member_names.append(name) - # Check for numbered ghost pattern (name-N where N is a number) if re.match(r'^.+-\d+$', name): numbered_ghosts.append(name) - # Check tmux backend with dead pane if isinstance(member, dict): backend = member.get("backendType", "") pane_id = member.get("paneId", member.get("tmuxPaneId", "")) @@ -134,6 +205,8 @@ if os.path.isdir(teams_dir): ghost_teams.append({ "name": entry, "path": team_path, + "repo": team_repo, + "cwd": team_cwd, "reasons": reasons, "numbered_ghosts": numbered_ghosts, "members": member_names @@ -141,6 +214,8 @@ if os.path.isdir(teams_dir): else: active_teams.append({ "name": entry, + "repo": team_repo, + "cwd": team_cwd, "members": member_names, "member_count": len(member_names) }) @@ -156,7 +231,6 @@ if os.path.isdir(tasks_dir): if entry in (".DS_Store",): continue - # A task is orphaned if no team config references it is_orphan = True if os.path.isdir(teams_dir): for team_entry in os.listdir(teams_dir):