feat(orphans): add list and kill subcommands for Claude process orphans

Add commands to find and terminate orphan Claude processes (those with
PPID=1 that survived session termination):

- gt orphans list: Show orphan Claude processes
- gt orphans kill: Kill with confirmation
- gt orphans kill -f: Force kill without confirmation

Detection excludes:
- tmux processes (may contain "claude" in args)
- Claude.app desktop application processes
- Claude Helper processes

The original `gt orphans` functionality for finding orphan git commits
is preserved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rictus
2026-01-13 12:02:20 -08:00
committed by beads/crew/emma
parent 87fde4b4fd
commit ea12679a5a
2 changed files with 234 additions and 2671 deletions

View File

@@ -4,9 +4,11 @@ import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
@@ -36,14 +38,52 @@ Examples:
}
var (
orphansDays int
orphansAll bool
orphansDays int
orphansAll bool
orphansForce bool
)
// Subcommands for process orphan management
var orphansListCmd = &cobra.Command{
Use: "list",
Short: "List orphaned Claude processes",
Long: `List Claude processes that have become orphaned (PPID=1).
These are processes that survived session termination and are now
parented to init/launchd. They consume resources and should be killed.
Excludes:
- tmux server processes
- Claude.app desktop application processes
Examples:
gt orphans list # Show all orphan Claude processes`,
RunE: runOrphansListProcesses,
}
var orphansKillCmd = &cobra.Command{
Use: "kill",
Short: "Kill orphaned Claude processes",
Long: `Kill Claude processes that have become orphaned (PPID=1).
Without flags, prompts for confirmation before killing.
Use -f/--force to kill without confirmation.
Examples:
gt orphans kill # Kill with confirmation
gt orphans kill -f # Force kill without confirmation`,
RunE: runOrphansKillProcesses,
}
func init() {
orphansCmd.Flags().IntVar(&orphansDays, "days", 7, "Show orphans from last N days")
orphansCmd.Flags().BoolVar(&orphansAll, "all", false, "Show all orphans (no date filter)")
orphansKillCmd.Flags().BoolVarP(&orphansForce, "force", "f", false, "Kill without confirmation")
orphansCmd.AddCommand(orphansListCmd)
orphansCmd.AddCommand(orphansKillCmd)
rootCmd.AddCommand(orphansCmd)
}
@@ -243,3 +283,195 @@ func formatAge(t time.Time) string {
}
return fmt.Sprintf("%d days ago", days)
}
// OrphanProcess represents a Claude process that has become orphaned (PPID=1)
type OrphanProcess struct {
PID int
Args string
}
// findOrphanProcesses finds Claude processes with PPID=1 (orphaned)
func findOrphanProcesses() ([]OrphanProcess, error) {
// Run ps to get all processes with PID, PPID, and args
cmd := exec.Command("ps", "-eo", "pid,ppid,args")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("running ps: %w", err)
}
var orphans []OrphanProcess
scanner := bufio.NewScanner(bytes.NewReader(out))
// Skip header line
if scanner.Scan() {
// First line is header, skip it
}
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
pid, err := strconv.Atoi(fields[0])
if err != nil {
continue
}
ppid, err := strconv.Atoi(fields[1])
if err != nil {
continue
}
// Only interested in orphans (PPID=1)
if ppid != 1 {
continue
}
// Reconstruct the args (rest of the fields)
args := strings.Join(fields[2:], " ")
// Check if it's a claude-related process
if !isClaudeProcess(args) {
continue
}
// Exclude processes we don't want to kill
if isExcludedProcess(args) {
continue
}
orphans = append(orphans, OrphanProcess{
PID: pid,
Args: args,
})
}
return orphans, nil
}
// isClaudeProcess checks if the process is claude-related
func isClaudeProcess(args string) bool {
argsLower := strings.ToLower(args)
return strings.Contains(argsLower, "claude")
}
// isExcludedProcess checks if the process should be excluded from orphan list
func isExcludedProcess(args string) bool {
// Exclude any tmux process (server, new-session, etc.)
// These may contain "claude" in args but are tmux processes, not actual Claude processes
if strings.HasPrefix(args, "tmux ") || strings.HasPrefix(args, "/usr/bin/tmux") {
return true
}
// Exclude Claude.app desktop application processes
if strings.Contains(args, "Claude.app") || strings.Contains(args, "/Applications/Claude") {
return true
}
// Exclude Claude Helper processes (part of Claude.app)
if strings.Contains(args, "Claude Helper") {
return true
}
return false
}
// runOrphansListProcesses lists orphaned Claude processes
func runOrphansListProcesses(cmd *cobra.Command, args []string) error {
orphans, err := findOrphanProcesses()
if err != nil {
return fmt.Errorf("finding orphan processes: %w", err)
}
if len(orphans) == 0 {
fmt.Printf("%s No orphaned Claude processes found\n", style.Bold.Render("✓"))
return nil
}
fmt.Printf("%s Found %d orphaned Claude process(es):\n\n", style.Warning.Render("⚠"), len(orphans))
for _, o := range orphans {
// Truncate args for display
displayArgs := o.Args
if len(displayArgs) > 80 {
displayArgs = displayArgs[:77] + "..."
}
fmt.Printf(" %s %s\n", style.Bold.Render(fmt.Sprintf("PID %d", o.PID)), displayArgs)
}
fmt.Printf("\n%s\n", style.Dim.Render("Use 'gt orphans kill' to terminate these processes"))
return nil
}
// runOrphansKillProcesses kills orphaned Claude processes
func runOrphansKillProcesses(cmd *cobra.Command, args []string) error {
orphans, err := findOrphanProcesses()
if err != nil {
return fmt.Errorf("finding orphan processes: %w", err)
}
if len(orphans) == 0 {
fmt.Printf("%s No orphaned Claude processes found\n", style.Bold.Render("✓"))
return nil
}
// Show what we're about to kill
fmt.Printf("%s Found %d orphaned Claude process(es):\n\n", style.Warning.Render("⚠"), len(orphans))
for _, o := range orphans {
displayArgs := o.Args
if len(displayArgs) > 80 {
displayArgs = displayArgs[:77] + "..."
}
fmt.Printf(" %s %s\n", style.Bold.Render(fmt.Sprintf("PID %d", o.PID)), displayArgs)
}
fmt.Println()
// Confirm unless --force
if !orphansForce {
fmt.Printf("Kill these %d process(es)? [y/N] ", len(orphans))
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
if response != "y" && response != "yes" {
fmt.Println("Aborted")
return nil
}
}
// Kill the processes
var killed, failed int
for _, o := range orphans {
proc, err := os.FindProcess(o.PID)
if err != nil {
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
failed++
continue
}
// Send SIGTERM first for graceful shutdown
if err := proc.Signal(syscall.SIGTERM); err != nil {
// Process may have already exited
if err == os.ErrProcessDone {
fmt.Printf(" %s PID %d: already terminated\n", style.Dim.Render("○"), o.PID)
continue
}
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
failed++
continue
}
fmt.Printf(" %s PID %d killed\n", style.Bold.Render("✓"), o.PID)
killed++
}
fmt.Printf("\n%s %d killed", style.Bold.Render("Summary:"), killed)
if failed > 0 {
fmt.Printf(", %d failed", failed)
}
fmt.Println()
return nil
}