Skip to content
Closed
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
100 changes: 80 additions & 20 deletions bin/ghost-scan.sh
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
#!/usr/bin/env bash
# ghost-scan.sh — Scan ~/.claude/teams/ and ~/.claude/tasks/ for ghost teams and orphan tasks.
# Outputs a JSON report to stdout. Uses python3 for all JSON operations (no jq dependency).
# Uses /usr/bin/find to avoid RTK hook interception.
#
# Session liveness detection:
# Uses ~/.claude/sessions/<PID>.json as the source of truth.
# Matches teams to sessions by cwd + createdAt≈startedAt (within 5s).
# This correctly handles in-process backends where UUIDs don't appear in ps.

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,12 +21,10 @@ 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
Expand All @@ -34,14 +33,63 @@ import re
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 the list of alive sessions from ~/.claude/sessions/*.json
# Each file is named <PID>.json: { pid, sessionId, cwd, startedAt, ... }
# A session is alive if its PID is still running (kill -0).
# ---------------------------------------------------------------------------
alive_sessions = [] # list of { 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)
pid = sess.get("pid")
cwd = sess.get("cwd", "")
started_at = sess.get("startedAt", 0)
if pid and cwd:
try:
os.kill(pid, 0)
alive_sessions.append({"cwd": cwd, "startedAt": started_at})
except (OSError, ProcessLookupError):
pass
except (json.JSONDecodeError, IOError, KeyError):
pass

# Max allowed gap between team createdAt and session startedAt (ms)
TIMESTAMP_TOLERANCE = 5000

def team_has_alive_session(team_config):
"""Check if a team matches any alive session by cwd + createdAt≈startedAt."""
members = team_config.get("members", [])
if isinstance(members, dict):
members = list(members.values())

team_cwds = set()
for member in members:
if isinstance(member, dict):
cwd = member.get("cwd", "")
if cwd:
team_cwds.add(cwd)

created_at = team_config.get("createdAt", 0)

for sess in alive_sessions:
if sess["cwd"] in team_cwds:
diff = abs(sess["startedAt"] - created_at)
if diff <= TIMESTAMP_TOLERANCE:
return True
return False

ghost_teams = []
active_teams = []

Expand All @@ -56,7 +104,6 @@ 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,
Expand Down Expand Up @@ -87,14 +134,15 @@ if os.path.isdir(teams_dir):
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:
reasons.append("no active session")
elif not lead_session:
reasons.append("no leadSessionId")
# Session liveness: cwd + createdAt≈startedAt
if not team_has_alive_session(config):
lead_session = config.get("leadSessionId", "")
if lead_session:
reasons.append("no active session")
else:
reasons.append("no leadSessionId")

# Check members
# Check members for ghosts and dead tmux panes
members = config.get("members", [])
if isinstance(members, dict):
members = list(members.values())
Expand Down Expand Up @@ -130,17 +178,29 @@ if os.path.isdir(teams_dir):
if numbered_ghosts and "numbered ghost agents" not in reasons:
reasons.append("numbered ghost agents")

# Extract repo name from member cwds
team_cwd = ""
for member in members:
if isinstance(member, dict) and member.get("cwd"):
team_cwd = member["cwd"]
break
repo_name = os.path.basename(team_cwd) if team_cwd else ""

if reasons:
ghost_teams.append({
"name": entry,
"path": team_path,
"repo": repo_name,
"cwd": team_cwd,
"reasons": reasons,
"numbered_ghosts": numbered_ghosts,
"members": member_names
})
else:
active_teams.append({
"name": entry,
"repo": repo_name,
"cwd": team_cwd,
"members": member_names,
"member_count": len(member_names)
})
Expand Down
Loading