Adds command to find and terminate orphan claude processes with PPID=1. Co-Authored-By: Claude <noreply@anthropic.com>
836 lines
24 KiB
Go
836 lines
24 KiB
Go
package cmd
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/steveyegge/gastown/internal/style"
|
||
"github.com/steveyegge/gastown/internal/util"
|
||
"github.com/steveyegge/gastown/internal/workspace"
|
||
)
|
||
|
||
var orphansCmd = &cobra.Command{
|
||
Use: "orphans",
|
||
GroupID: GroupWork,
|
||
Short: "Find lost polecat work",
|
||
Long: `Find orphaned commits that were never merged to main.
|
||
|
||
Polecat work can get lost when:
|
||
- Session killed before merge
|
||
- Refinery fails to process
|
||
- Network issues during push
|
||
|
||
This command uses 'git fsck --unreachable' to find dangling commits,
|
||
filters to recent ones, and shows details to help recovery.
|
||
|
||
Examples:
|
||
gt orphans # Last 7 days (default)
|
||
gt orphans --days=14 # Last 2 weeks
|
||
gt orphans --all # Show all orphans (no date filter)`,
|
||
RunE: runOrphans,
|
||
}
|
||
|
||
var (
|
||
orphansDays int
|
||
orphansAll bool
|
||
|
||
// Kill commits command flags
|
||
orphansKillDryRun bool
|
||
orphansKillDays int
|
||
orphansKillAll bool
|
||
orphansKillForce bool
|
||
|
||
// Process orphan flags
|
||
orphansProcsForce bool
|
||
orphansProcsAggressive bool
|
||
)
|
||
|
||
// Commit orphan kill command
|
||
var orphansKillCmd = &cobra.Command{
|
||
Use: "kill",
|
||
Short: "Remove all orphans (commits and processes)",
|
||
Long: `Remove orphaned commits and kill orphaned Claude processes.
|
||
|
||
This command performs a complete orphan cleanup:
|
||
1. Finds and removes orphaned commits via 'git gc --prune=now'
|
||
2. Finds and kills orphaned Claude processes (PPID=1)
|
||
|
||
WARNING: This operation is irreversible. Once commits are pruned,
|
||
they cannot be recovered.
|
||
|
||
The command will:
|
||
1. Find orphaned commits (same as 'gt orphans')
|
||
2. Find orphaned Claude processes (same as 'gt orphans procs')
|
||
3. Show what will be removed/killed
|
||
4. Ask for confirmation (unless --force)
|
||
5. Run git gc and kill processes
|
||
|
||
Examples:
|
||
gt orphans kill # Kill orphans from last 7 days (default)
|
||
gt orphans kill --days=14 # Kill orphans from last 2 weeks
|
||
gt orphans kill --all # Kill all orphans
|
||
gt orphans kill --dry-run # Preview without deleting
|
||
gt orphans kill --force # Skip confirmation prompt`,
|
||
RunE: runOrphansKill,
|
||
}
|
||
|
||
// Process orphan commands
|
||
var orphansProcsCmd = &cobra.Command{
|
||
Use: "procs",
|
||
Short: "Manage orphaned Claude processes",
|
||
Long: `Find and kill 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.
|
||
|
||
Use --aggressive to detect ALL orphaned Claude processes by cross-referencing
|
||
against active tmux sessions. Any Claude process NOT in a gt-* or hq-* session
|
||
is considered an orphan. This catches processes that have been reparented to
|
||
something other than init (PPID != 1).
|
||
|
||
Examples:
|
||
gt orphans procs # List orphaned Claude processes (PPID=1 only)
|
||
gt orphans procs list # Same as above
|
||
gt orphans procs --aggressive # List ALL orphaned processes (tmux verification)
|
||
gt orphans procs kill # Kill orphaned processes`,
|
||
RunE: runOrphansListProcesses, // Default to list
|
||
}
|
||
|
||
var orphansProcsListCmd = &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.
|
||
|
||
Use --aggressive to detect ALL orphaned Claude processes by cross-referencing
|
||
against active tmux sessions. Any Claude process NOT in a gt-* or hq-* session
|
||
is considered an orphan.
|
||
|
||
Excludes:
|
||
- tmux server processes
|
||
- Claude.app desktop application processes
|
||
|
||
Examples:
|
||
gt orphans procs list # Show orphans with PPID=1
|
||
gt orphans procs list --aggressive # Show ALL orphans (tmux verification)`,
|
||
RunE: runOrphansListProcesses,
|
||
}
|
||
|
||
var orphansProcsKillCmd = &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.
|
||
Use --aggressive to kill ALL orphaned processes (not just PPID=1).
|
||
|
||
Examples:
|
||
gt orphans procs kill # Kill with confirmation
|
||
gt orphans procs kill -f # Force kill without confirmation
|
||
gt orphans procs kill --aggressive # Kill ALL orphans (tmux verification)`,
|
||
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)")
|
||
|
||
// Kill commits command flags
|
||
orphansKillCmd.Flags().BoolVar(&orphansKillDryRun, "dry-run", false, "Preview without deleting")
|
||
orphansKillCmd.Flags().IntVar(&orphansKillDays, "days", 7, "Kill orphans from last N days")
|
||
orphansKillCmd.Flags().BoolVar(&orphansKillAll, "all", false, "Kill all orphans (no date filter)")
|
||
orphansKillCmd.Flags().BoolVar(&orphansKillForce, "force", false, "Skip confirmation prompt")
|
||
|
||
// Process orphan kill command flags
|
||
orphansProcsKillCmd.Flags().BoolVarP(&orphansProcsForce, "force", "f", false, "Kill without confirmation")
|
||
|
||
// Aggressive flag for all procs commands (persistent so it applies to subcommands)
|
||
orphansProcsCmd.PersistentFlags().BoolVar(&orphansProcsAggressive, "aggressive", false, "Use tmux session verification to find ALL orphans (not just PPID=1)")
|
||
|
||
// Wire up subcommands
|
||
orphansProcsCmd.AddCommand(orphansProcsListCmd)
|
||
orphansProcsCmd.AddCommand(orphansProcsKillCmd)
|
||
|
||
orphansCmd.AddCommand(orphansKillCmd)
|
||
orphansCmd.AddCommand(orphansProcsCmd)
|
||
|
||
rootCmd.AddCommand(orphansCmd)
|
||
}
|
||
|
||
// OrphanCommit represents an unreachable commit
|
||
type OrphanCommit struct {
|
||
SHA string
|
||
Date time.Time
|
||
Author string
|
||
Subject string
|
||
}
|
||
|
||
func runOrphans(cmd *cobra.Command, args []string) error {
|
||
// Find workspace to determine rig root
|
||
townRoot, err := workspace.FindFromCwdOrError()
|
||
if err != nil {
|
||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||
}
|
||
|
||
// Find current rig
|
||
rigName, r, err := findCurrentRig(townRoot)
|
||
if err != nil {
|
||
return fmt.Errorf("determining rig: %w", err)
|
||
}
|
||
|
||
// We need to run from the mayor's clone (main git repo for the rig)
|
||
mayorPath := r.Path + "/mayor/rig"
|
||
|
||
fmt.Printf("Scanning for orphaned commits in %s...\n\n", rigName)
|
||
|
||
// Run git fsck
|
||
orphans, err := findOrphanCommits(mayorPath)
|
||
if err != nil {
|
||
return fmt.Errorf("finding orphans: %w", err)
|
||
}
|
||
|
||
if len(orphans) == 0 {
|
||
fmt.Printf("%s No orphaned commits found\n", style.Bold.Render("✓"))
|
||
return nil
|
||
}
|
||
|
||
// Filter by date unless --all
|
||
cutoff := time.Now().AddDate(0, 0, -orphansDays)
|
||
var filtered []OrphanCommit
|
||
|
||
for _, o := range orphans {
|
||
if orphansAll || o.Date.After(cutoff) {
|
||
filtered = append(filtered, o)
|
||
}
|
||
}
|
||
|
||
if len(filtered) == 0 {
|
||
fmt.Printf("%s No orphaned commits in the last %d days\n", style.Bold.Render("✓"), orphansDays)
|
||
fmt.Printf("%s Use --days=N or --all to see older orphans\n", style.Dim.Render("Hint:"))
|
||
return nil
|
||
}
|
||
|
||
// Display results
|
||
fmt.Printf("%s Found %d orphaned commit(s):\n\n", style.Warning.Render("⚠"), len(filtered))
|
||
|
||
for _, o := range filtered {
|
||
age := formatAge(o.Date)
|
||
fmt.Printf(" %s %s\n", style.Bold.Render(o.SHA[:8]), o.Subject)
|
||
fmt.Printf(" %s by %s\n\n", style.Dim.Render(age), o.Author)
|
||
}
|
||
|
||
// Recovery hints
|
||
fmt.Printf("%s\n", style.Dim.Render("To recover a commit:"))
|
||
fmt.Printf("%s\n", style.Dim.Render(" git cherry-pick <sha> # Apply to current branch"))
|
||
fmt.Printf("%s\n", style.Dim.Render(" git show <sha> # View full commit"))
|
||
fmt.Printf("%s\n", style.Dim.Render(" git branch rescue <sha> # Create branch from commit"))
|
||
|
||
return nil
|
||
}
|
||
|
||
// findOrphanCommits runs git fsck and parses orphaned commits
|
||
func findOrphanCommits(repoPath string) ([]OrphanCommit, error) {
|
||
// Run git fsck to find unreachable objects
|
||
fsckCmd := exec.Command("git", "fsck", "--unreachable", "--no-reflogs")
|
||
fsckCmd.Dir = repoPath
|
||
|
||
var fsckOut, fsckErr bytes.Buffer
|
||
fsckCmd.Stdout = &fsckOut
|
||
fsckCmd.Stderr = &fsckErr
|
||
|
||
if err := fsckCmd.Run(); err != nil {
|
||
// git fsck returns non-zero if there are issues, but we still get output
|
||
// Only fail if we got no output at all
|
||
if fsckOut.Len() == 0 {
|
||
// Include stderr in error message for debugging
|
||
errMsg := strings.TrimSpace(fsckErr.String())
|
||
if errMsg != "" {
|
||
return nil, fmt.Errorf("git fsck failed: %w (%s)", err, errMsg)
|
||
}
|
||
return nil, fmt.Errorf("git fsck failed: %w", err)
|
||
}
|
||
}
|
||
|
||
// Parse commit SHAs from output
|
||
var commitSHAs []string
|
||
scanner := bufio.NewScanner(&fsckOut)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
// Format: "unreachable commit <sha>"
|
||
if strings.HasPrefix(line, "unreachable commit ") {
|
||
sha := strings.TrimPrefix(line, "unreachable commit ")
|
||
commitSHAs = append(commitSHAs, sha)
|
||
}
|
||
}
|
||
|
||
if len(commitSHAs) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// Get details for each commit
|
||
var orphans []OrphanCommit
|
||
for _, sha := range commitSHAs {
|
||
commit, err := getCommitDetails(repoPath, sha)
|
||
if err != nil {
|
||
continue // Skip commits we can't parse
|
||
}
|
||
|
||
// Skip stash-like and routine sync commits
|
||
if isNoiseCommit(commit.Subject) {
|
||
continue
|
||
}
|
||
|
||
orphans = append(orphans, commit)
|
||
}
|
||
|
||
return orphans, nil
|
||
}
|
||
|
||
// getCommitDetails retrieves commit metadata
|
||
func getCommitDetails(repoPath, sha string) (OrphanCommit, error) {
|
||
// Format: timestamp|author|subject
|
||
cmd := exec.Command("git", "log", "-1", "--format=%at|%an|%s", sha)
|
||
cmd.Dir = repoPath
|
||
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return OrphanCommit{}, err
|
||
}
|
||
|
||
parts := strings.SplitN(strings.TrimSpace(string(out)), "|", 3)
|
||
if len(parts) < 3 {
|
||
return OrphanCommit{}, fmt.Errorf("unexpected format")
|
||
}
|
||
|
||
timestamp, err := strconv.ParseInt(parts[0], 10, 64)
|
||
if err != nil {
|
||
return OrphanCommit{}, err
|
||
}
|
||
|
||
return OrphanCommit{
|
||
SHA: sha,
|
||
Date: time.Unix(timestamp, 0),
|
||
Author: parts[1],
|
||
Subject: parts[2],
|
||
}, nil
|
||
}
|
||
|
||
// isNoiseCommit returns true for stash-related or routine sync commits
|
||
func isNoiseCommit(subject string) bool {
|
||
// Git stash creates commits with these prefixes
|
||
noisePrefixes := []string{
|
||
"WIP on ",
|
||
"index on ",
|
||
"On ", // "On branch: message"
|
||
"stash@{", // Direct stash reference
|
||
"untracked files ", // Stash with untracked
|
||
"bd sync:", // Beads sync commits (routine)
|
||
"bd sync: ", // Beads sync commits (routine)
|
||
}
|
||
|
||
for _, prefix := range noisePrefixes {
|
||
if strings.HasPrefix(subject, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// formatAge returns a human-readable age string
|
||
func formatAge(t time.Time) string {
|
||
d := time.Since(t)
|
||
|
||
if d < time.Hour {
|
||
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
|
||
}
|
||
if d < 24*time.Hour {
|
||
return fmt.Sprintf("%d hours ago", int(d.Hours()))
|
||
}
|
||
days := int(d.Hours() / 24)
|
||
if days == 1 {
|
||
return "1 day ago"
|
||
}
|
||
return fmt.Sprintf("%d days ago", days)
|
||
}
|
||
|
||
// runOrphansKill removes orphaned commits and kills orphaned processes
|
||
func runOrphansKill(cmd *cobra.Command, args []string) error {
|
||
townRoot, err := workspace.FindFromCwdOrError()
|
||
if err != nil {
|
||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||
}
|
||
|
||
rigName, r, err := findCurrentRig(townRoot)
|
||
if err != nil {
|
||
return fmt.Errorf("determining rig: %w", err)
|
||
}
|
||
|
||
mayorPath := r.Path + "/mayor/rig"
|
||
|
||
// Find orphaned commits
|
||
fmt.Printf("Scanning for orphaned commits in %s...\n", rigName)
|
||
commitOrphans, err := findOrphanCommits(mayorPath)
|
||
if err != nil {
|
||
return fmt.Errorf("finding orphan commits: %w", err)
|
||
}
|
||
|
||
// Filter commits by date
|
||
cutoff := time.Now().AddDate(0, 0, -orphansKillDays)
|
||
var filteredCommits []OrphanCommit
|
||
for _, o := range commitOrphans {
|
||
if orphansKillAll || o.Date.After(cutoff) {
|
||
filteredCommits = append(filteredCommits, o)
|
||
}
|
||
}
|
||
|
||
// Find orphaned processes
|
||
fmt.Printf("Scanning for orphaned Claude processes...\n\n")
|
||
procOrphans, err := findOrphanProcesses()
|
||
if err != nil {
|
||
return fmt.Errorf("finding orphan processes: %w", err)
|
||
}
|
||
|
||
// Check if there's anything to do
|
||
if len(filteredCommits) == 0 && len(procOrphans) == 0 {
|
||
fmt.Printf("%s No orphans found\n", style.Bold.Render("✓"))
|
||
return nil
|
||
}
|
||
|
||
// Show orphaned commits
|
||
if len(filteredCommits) > 0 {
|
||
fmt.Printf("%s Found %d orphaned commit(s) to remove:\n\n", style.Warning.Render("⚠"), len(filteredCommits))
|
||
for _, o := range filteredCommits {
|
||
fmt.Printf(" %s %s\n", style.Bold.Render(o.SHA[:8]), o.Subject)
|
||
fmt.Printf(" %s by %s\n\n", style.Dim.Render(formatAge(o.Date)), o.Author)
|
||
}
|
||
} else if len(commitOrphans) > 0 {
|
||
fmt.Printf("%s No orphaned commits in the last %d days (use --days=N or --all)\n\n",
|
||
style.Dim.Render("ℹ"), orphansKillDays)
|
||
}
|
||
|
||
// Show orphaned processes
|
||
if len(procOrphans) > 0 {
|
||
fmt.Printf("%s Found %d orphaned Claude process(es) to kill:\n\n", style.Warning.Render("⚠"), len(procOrphans))
|
||
for _, o := range procOrphans {
|
||
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()
|
||
}
|
||
|
||
if orphansKillDryRun {
|
||
fmt.Printf("%s Dry run - no changes made\n", style.Dim.Render("ℹ"))
|
||
return nil
|
||
}
|
||
|
||
// Confirmation
|
||
if !orphansKillForce {
|
||
fmt.Printf("%s\n", style.Warning.Render("WARNING: This operation is irreversible!"))
|
||
total := len(filteredCommits) + len(procOrphans)
|
||
fmt.Printf("Remove %d orphan(s)? [y/N] ", total)
|
||
var response string
|
||
_, _ = fmt.Scanln(&response)
|
||
if strings.ToLower(strings.TrimSpace(response)) != "y" {
|
||
fmt.Printf("%s Canceled\n", style.Dim.Render("ℹ"))
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// Kill orphaned commits
|
||
if len(filteredCommits) > 0 {
|
||
fmt.Printf("\nRunning git gc --prune=now...\n")
|
||
gcCmd := exec.Command("git", "gc", "--prune=now")
|
||
gcCmd.Dir = mayorPath
|
||
gcCmd.Stdout = os.Stdout
|
||
gcCmd.Stderr = os.Stderr
|
||
if err := gcCmd.Run(); err != nil {
|
||
return fmt.Errorf("git gc failed: %w", err)
|
||
}
|
||
fmt.Printf("%s Removed %d orphaned commit(s)\n", style.Bold.Render("✓"), len(filteredCommits))
|
||
}
|
||
|
||
// Kill orphaned processes
|
||
if len(procOrphans) > 0 {
|
||
fmt.Printf("\nKilling orphaned processes...\n")
|
||
// Use SIGKILL with --force for immediate termination, SIGTERM otherwise
|
||
signal := syscall.SIGTERM
|
||
if orphansKillForce {
|
||
signal = syscall.SIGKILL
|
||
}
|
||
|
||
var killed, failed int
|
||
for _, o := range procOrphans {
|
||
proc, err := os.FindProcess(o.PID)
|
||
if err != nil {
|
||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), o.PID, err)
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
if err := proc.Signal(signal); err != nil {
|
||
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("%s %d process(es) killed", style.Bold.Render("✓"), killed)
|
||
if failed > 0 {
|
||
fmt.Printf(", %d failed", failed)
|
||
}
|
||
fmt.Println()
|
||
}
|
||
|
||
fmt.Printf("\n%s Orphan cleanup complete\n", style.Bold.Render("✓"))
|
||
return nil
|
||
}
|
||
|
||
// 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 {
|
||
if orphansProcsAggressive {
|
||
return runOrphansListProcessesAggressive()
|
||
}
|
||
|
||
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 (PPID=1)\n", style.Bold.Render("✓"))
|
||
fmt.Printf("%s Use --aggressive to find orphans via tmux session verification\n", style.Dim.Render("Hint:"))
|
||
return nil
|
||
}
|
||
|
||
fmt.Printf("%s Found %d orphaned Claude process(es) with PPID=1:\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 procs kill' to terminate these processes"))
|
||
fmt.Printf("%s\n", style.Dim.Render("Use --aggressive to find more orphans via tmux session verification"))
|
||
|
||
return nil
|
||
}
|
||
|
||
// runOrphansListProcessesAggressive lists orphans using tmux session verification.
|
||
// This finds ALL Claude processes not in any gt-* or hq-* tmux session.
|
||
func runOrphansListProcessesAggressive() error {
|
||
zombies, err := util.FindZombieClaudeProcesses()
|
||
if err != nil {
|
||
return fmt.Errorf("finding zombie processes: %w", err)
|
||
}
|
||
|
||
if len(zombies) == 0 {
|
||
fmt.Printf("%s No orphaned Claude processes found (aggressive mode)\n", style.Bold.Render("✓"))
|
||
return nil
|
||
}
|
||
|
||
fmt.Printf("%s Found %d orphaned Claude process(es) not in any tmux session:\n\n", style.Warning.Render("⚠"), len(zombies))
|
||
|
||
for _, z := range zombies {
|
||
ageStr := formatProcessAge(z.Age)
|
||
fmt.Printf(" %s %s (age: %s, tty: %s)\n",
|
||
style.Bold.Render(fmt.Sprintf("PID %d", z.PID)),
|
||
z.Cmd,
|
||
style.Dim.Render(ageStr),
|
||
z.TTY)
|
||
}
|
||
|
||
fmt.Printf("\n%s\n", style.Dim.Render("Use 'gt orphans procs kill --aggressive' to terminate these processes"))
|
||
|
||
return nil
|
||
}
|
||
|
||
// formatProcessAge formats seconds into a human-readable age string
|
||
func formatProcessAge(seconds int) string {
|
||
if seconds < 60 {
|
||
return fmt.Sprintf("%ds", seconds)
|
||
}
|
||
if seconds < 3600 {
|
||
return fmt.Sprintf("%dm%ds", seconds/60, seconds%60)
|
||
}
|
||
hours := seconds / 3600
|
||
mins := (seconds % 3600) / 60
|
||
return fmt.Sprintf("%dh%dm", hours, mins)
|
||
}
|
||
|
||
// runOrphansKillProcesses kills orphaned Claude processes
|
||
func runOrphansKillProcesses(cmd *cobra.Command, args []string) error {
|
||
if orphansProcsAggressive {
|
||
return runOrphansKillProcessesAggressive()
|
||
}
|
||
|
||
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 (PPID=1)\n", style.Bold.Render("✓"))
|
||
fmt.Printf("%s Use --aggressive to find orphans via tmux session verification\n", style.Dim.Render("Hint:"))
|
||
return nil
|
||
}
|
||
|
||
// Show what we're about to kill
|
||
fmt.Printf("%s Found %d orphaned Claude process(es) with PPID=1:\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 !orphansProcsForce {
|
||
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
|
||
// Use SIGKILL with --force for immediate termination, SIGTERM otherwise
|
||
signal := syscall.SIGTERM
|
||
if orphansProcsForce {
|
||
signal = syscall.SIGKILL
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
if err := proc.Signal(signal); 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
|
||
}
|
||
|
||
// runOrphansKillProcessesAggressive kills orphans using tmux session verification.
|
||
// This kills ALL Claude processes not in any gt-* or hq-* tmux session.
|
||
func runOrphansKillProcessesAggressive() error {
|
||
zombies, err := util.FindZombieClaudeProcesses()
|
||
if err != nil {
|
||
return fmt.Errorf("finding zombie processes: %w", err)
|
||
}
|
||
|
||
if len(zombies) == 0 {
|
||
fmt.Printf("%s No orphaned Claude processes found (aggressive mode)\n", style.Bold.Render("✓"))
|
||
return nil
|
||
}
|
||
|
||
// Show what we're about to kill
|
||
fmt.Printf("%s Found %d orphaned Claude process(es) not in any tmux session:\n\n", style.Warning.Render("⚠"), len(zombies))
|
||
for _, z := range zombies {
|
||
ageStr := formatProcessAge(z.Age)
|
||
fmt.Printf(" %s %s (age: %s, tty: %s)\n",
|
||
style.Bold.Render(fmt.Sprintf("PID %d", z.PID)),
|
||
z.Cmd,
|
||
style.Dim.Render(ageStr),
|
||
z.TTY)
|
||
}
|
||
fmt.Println()
|
||
|
||
// Confirm unless --force
|
||
if !orphansProcsForce {
|
||
fmt.Printf("Kill these %d process(es)? [y/N] ", len(zombies))
|
||
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
|
||
// Use SIGKILL with --force for immediate termination, SIGTERM otherwise
|
||
signal := syscall.SIGTERM
|
||
if orphansProcsForce {
|
||
signal = syscall.SIGKILL
|
||
}
|
||
|
||
var killed, failed int
|
||
for _, z := range zombies {
|
||
proc, err := os.FindProcess(z.PID)
|
||
if err != nil {
|
||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), z.PID, err)
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
if err := proc.Signal(signal); err != nil {
|
||
// Process may have already exited
|
||
if err == os.ErrProcessDone {
|
||
fmt.Printf(" %s PID %d: already terminated\n", style.Dim.Render("○"), z.PID)
|
||
continue
|
||
}
|
||
fmt.Printf(" %s PID %d: %v\n", style.Error.Render("✗"), z.PID, err)
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
fmt.Printf(" %s PID %d killed\n", style.Bold.Render("✓"), z.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
|
||
}
|