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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
114 changes: 94 additions & 20 deletions bin/ghost-scan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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/<PID>.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 = []

Expand All @@ -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": []
Expand All @@ -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": []
Expand All @@ -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", [])
Expand All @@ -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", ""))
Expand All @@ -134,13 +205,17 @@ 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
})
else:
active_teams.append({
"name": entry,
"repo": team_repo,
"cwd": team_cwd,
"members": member_names,
"member_count": len(member_names)
})
Expand All @@ -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):
Expand Down
Loading