From ad6386809c78ce714c168dbc3305999475245011 Mon Sep 17 00:00:00 2001 From: dennis Date: Sat, 10 Jan 2026 16:32:59 -0800 Subject: [PATCH] fix(crew): detect running sessions started with shell compound commands IsClaudeRunning now checks for child processes when the pane command is a shell (bash/zsh). This fixes gt crew start --all killing running crew members that were started with "export ... && claude ..." commands. Co-Authored-By: Claude Opus 4.5 --- internal/tmux/tmux.go | 54 +++++++++++++++++++++++++++++- internal/tmux/tmux_test.go | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index f51a187f..afa03fd0 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -406,6 +406,43 @@ func (t *Tmux) GetPaneWorkDir(session string) (string, error) { return strings.TrimSpace(out), nil } +// GetPanePID returns the PID of the pane's main process. +func (t *Tmux) GetPanePID(session string) (string, error) { + out, err := t.run("list-panes", "-t", session, "-F", "#{pane_pid}") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// hasClaudeChild checks if a process has a child running claude/node. +// Used when the pane command is a shell (bash, zsh) that launched claude. +func hasClaudeChild(pid string) bool { + // Use pgrep to find child processes + cmd := exec.Command("pgrep", "-P", pid, "-l") + out, err := cmd.Output() + if err != nil { + return false + } + // Check if any child is node or claude + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Format: "PID name" e.g., "29677 node" + parts := strings.Fields(line) + if len(parts) >= 2 { + name := parts[1] + if name == "node" || name == "claude" { + return true + } + } + } + return false +} + // FindSessionByWorkDir finds tmux sessions where the pane's current working directory // matches or is under the target directory. Returns session names that match. // If processNames is provided, only returns sessions that match those processes. @@ -597,6 +634,7 @@ func (t *Tmux) IsAgentRunning(session string, expectedPaneCommands ...string) bo // IsClaudeRunning checks if Claude appears to be running in the session. // Only trusts the pane command - UI markers in scrollback cause false positives. // Claude can report as "node", "claude", or a version number like "2.0.76". +// Also checks for child processes when the pane is a shell running claude via "bash -c". func (t *Tmux) IsClaudeRunning(session string) bool { // Check for known command names first if t.IsAgentRunning(session, "node", "claude") { @@ -608,7 +646,21 @@ func (t *Tmux) IsClaudeRunning(session string) bool { return false } matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+`, cmd) - return matched + if matched { + return true + } + // If pane command is a shell, check for claude/node child processes. + // This handles the case where sessions are started with "bash -c 'export ... && claude ...'" + for _, shell := range constants.SupportedShells { + if cmd == shell { + pid, err := t.GetPanePID(session) + if err == nil && pid != "" { + return hasClaudeChild(pid) + } + break + } + } + return false } // IsRuntimeRunning checks if a runtime appears to be running in the session. diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 13668dcd..b54a0b1d 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -460,3 +460,70 @@ func TestIsClaudeRunning_VersionPattern(t *testing.T) { }) } } + +func TestIsClaudeRunning_ShellWithNodeChild(t *testing.T) { + if !hasTmux() { + t.Skip("tmux not installed") + } + + tm := NewTmux() + sessionName := "gt-test-shell-child-" + t.Name() + + // Clean up any existing session + _ = tm.KillSession(sessionName) + + // Create session with "bash -c" running a node process + // Use a simple node command that runs for a few seconds + cmd := `node -e "setTimeout(() => {}, 10000)"` + if err := tm.NewSessionWithCommand(sessionName, "", cmd); err != nil { + t.Fatalf("NewSessionWithCommand: %v", err) + } + defer func() { _ = tm.KillSession(sessionName) }() + + // Give the node process time to start + // WaitForCommand waits until NOT running bash/zsh/sh + shellsToExclude := []string{"bash", "zsh", "sh"} + err := tm.WaitForCommand(sessionName, shellsToExclude, 2000*1000000) // 2 second timeout + if err != nil { + // If we timeout waiting, it means the pane command is still a shell + // This is the case we're testing - shell with a node child + paneCmd, _ := tm.GetPaneCommand(sessionName) + t.Logf("Pane command is %q - testing shell+child detection", paneCmd) + } + + // Now test IsClaudeRunning - it should detect node as a child process + paneCmd, _ := tm.GetPaneCommand(sessionName) + if paneCmd == "node" { + // Direct node detection should work + if !tm.IsClaudeRunning(sessionName) { + t.Error("IsClaudeRunning should return true when pane command is 'node'") + } + } else { + // Pane is a shell (bash/zsh) with node as child + // The new child process detection should catch this + got := tm.IsClaudeRunning(sessionName) + t.Logf("Pane command: %q, IsClaudeRunning: %v", paneCmd, got) + // Note: This may or may not detect depending on how tmux runs the command. + // On some systems, tmux runs the command directly; on others via a shell. + } +} + +func TestHasClaudeChild(t *testing.T) { + // Test the hasClaudeChild helper function directly + // This uses the current process as a test subject + + // Get current process PID as string + currentPID := "1" // init/launchd - should have children but not claude/node + + // hasClaudeChild should return false for init (no node/claude children) + got := hasClaudeChild(currentPID) + if got { + t.Logf("hasClaudeChild(%q) = true - init has claude/node child?", currentPID) + } + + // Test with a definitely nonexistent PID + got = hasClaudeChild("999999999") + if got { + t.Error("hasClaudeChild should return false for nonexistent PID") + } +}