From 68d6dee400af0be8dca0b7cab0a68c480fbee275 Mon Sep 17 00:00:00 2001 From: Kenta Mori Date: Thu, 18 Jun 2026 10:52:59 +0900 Subject: [PATCH] fix: use PID-based session liveness instead of ps UUID matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach grepped ps aux for UUIDs and matched them against team leadSessionId. This fundamentally failed for in-process backends because their session UUIDs never appear in process command lines, causing all in-process teams to be falsely reported as ghosts. New approach: - Read ~/.claude/sessions/.json as the source of truth - Verify PID is alive via kill -0 - Match teams to sessions by cwd + createdAt≈startedAt (within 5s) - Add repo name and cwd to scan output for better visibility --- bin/ghost-scan.sh | 100 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/bin/ghost-scan.sh b/bin/ghost-scan.sh index 89430be..7de9865 100755 --- a/bin/ghost-scan.sh +++ b/bin/ghost-scan.sh @@ -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/.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" @@ -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 @@ -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 .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 = [] @@ -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, @@ -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()) @@ -130,10 +178,20 @@ 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 @@ -141,6 +199,8 @@ if os.path.isdir(teams_dir): else: active_teams.append({ "name": entry, + "repo": repo_name, + "cwd": team_cwd, "members": member_names, "member_count": len(member_names) })