fix(orphan): prevent Claude Code session leaks on macOS

Three bugs were causing orphaned Claude processes to accumulate:

1. TTY comparison in orphan.go checked for "?" but macOS shows "??"
   - Orphan cleanup never found anything on macOS
   - Changed to check for both "?" and "??"

2. selfKillSession in done.go used basic tmux kill-session
   - Claude Code can survive SIGHUP
   - Now uses KillSessionWithProcesses for proper cleanup

3. Crew stop commands used basic KillSession
   - Same issue as #2
   - Updated runCrewRemove, runCrewStop, runCrewStopAll

Root cause of 383 accumulated sessions: every gt done and crew stop
left orphans, and the cleanup never worked on macOS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-17 03:49:18 -08:00
committed by beads/crew/emma
parent 4a856f6e0d
commit 2feefd1731
3 changed files with 14 additions and 13 deletions

View File

@@ -60,11 +60,11 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
}
}
// Kill session if it exists
// Kill session if it exists (with proper process cleanup to avoid orphans)
t := tmux.NewTmux()
sessionID := crewSessionName(r.Name, name)
if hasSession, _ := t.HasSession(sessionID); hasSession {
if err := t.KillSession(sessionID); err != nil {
if err := t.KillSessionWithProcesses(sessionID); err != nil {
fmt.Printf("Error killing session for %s: %v\n", arg, err)
lastErr = err
continue
@@ -591,8 +591,8 @@ func runCrewStop(cmd *cobra.Command, args []string) error {
output, _ = t.CapturePane(sessionID, 50)
}
// Kill the session
if err := t.KillSession(sessionID); err != nil {
// Kill the session (with proper process cleanup to avoid orphans)
if err := t.KillSessionWithProcesses(sessionID); err != nil {
fmt.Printf(" %s [%s] %s: %s\n",
style.ErrorPrefix,
r.Name, name,
@@ -681,8 +681,8 @@ func runCrewStopAll() error {
output, _ = t.CapturePane(sessionID, 50)
}
// Kill the session
if err := t.KillSession(sessionID); err != nil {
// Kill the session (with proper process cleanup to avoid orphans)
if err := t.KillSessionWithProcesses(sessionID); err != nil {
failed++
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)

View File

@@ -3,7 +3,6 @@ package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -15,6 +14,7 @@ import (
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -743,11 +743,11 @@ func selfKillSession(townRoot string, roleInfo RoleInfo) error {
_ = events.LogFeed(events.TypeSessionDeath, agentID,
events.SessionDeathPayload(sessionName, agentID, "self-clean: done means gone", "gt done"))
// Kill our own tmux session
// This will terminate Claude and the shell, completing the self-cleaning cycle.
// We use exec.Command instead of the tmux package to avoid import cycles.
cmd := exec.Command("tmux", "kill-session", "-t", sessionName) //nolint:gosec // G204: sessionName is derived from env vars, not user input
if err := cmd.Run(); err != nil {
// Kill our own tmux session with proper process cleanup
// This will terminate Claude and all child processes, completing the self-cleaning cycle.
// We use KillSessionWithProcesses to ensure no orphaned processes are left behind.
t := tmux.NewTmux()
if err := t.KillSessionWithProcesses(sessionName); err != nil {
return fmt.Errorf("killing session %s: %w", sessionName, err)
}

View File

@@ -191,7 +191,8 @@ func FindOrphanedClaudeProcesses() ([]OrphanedProcess, error) {
etimeStr := fields[3]
// Only look for claude/codex processes without a TTY
if tty != "?" {
// Linux shows "?" for no TTY, macOS shows "??"
if tty != "?" && tty != "??" {
continue
}