Files
gastown/internal/cmd/orphans.go
mayor 6e2169de7f feat(orphans): add gt orphans kill command
Adds command to find and terminate orphan claude processes with PPID=1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:04:00 -08:00

836 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}