fix: IsClaudeRunning detects 'claude' and version patterns

Claude Code can report its pane command as "node", "claude", or a version
number like "2.0.76". Previously only "node" was detected, causing healthy
sessions to be incorrectly identified as zombies and killed during daemon
heartbeat recovery.

This fix detects all three patterns to prevent witness sessions from being
killed every 3 minutes.

Based on michaellady's work in PR #174.

Co-Authored-By: michaellady <michaellady@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-08 02:25:15 -08:00
parent f4cbcb4ce9
commit caa88d96c5
2 changed files with 50 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
@@ -556,9 +557,19 @@ 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".
func (t *Tmux) IsClaudeRunning(session string) bool {
// Claude runs as node
return t.IsAgentRunning(session, "node")
// Check for known command names first
if t.IsAgentRunning(session, "node", "claude") {
return true
}
// Check for version pattern (e.g., "2.0.76") - Claude Code shows version as pane command
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+`, cmd)
return matched
}
// WaitForCommand polls until the pane is NOT running one of the excluded commands.

View File

@@ -2,6 +2,7 @@ package tmux
import (
"os/exec"
"regexp"
"strings"
"testing"
)
@@ -417,11 +418,45 @@ func TestIsClaudeRunning(t *testing.T) {
}
defer func() { _ = tm.KillSession(sessionName) }()
// IsClaudeRunning should be false (shell is running, not node)
// IsClaudeRunning should be false (shell is running, not node/claude)
cmd, _ := tm.GetPaneCommand(sessionName)
wantRunning := cmd == "node"
wantRunning := cmd == "node" || cmd == "claude"
if got := tm.IsClaudeRunning(sessionName); got != wantRunning {
t.Errorf("IsClaudeRunning() = %v, want %v (pane cmd: %q)", got, wantRunning, cmd)
}
}
func TestIsClaudeRunning_VersionPattern(t *testing.T) {
// Test the version pattern regex matching directly
// Since we can't easily mock the pane command, test the pattern logic
tests := []struct {
cmd string
want bool
}{
{"node", true},
{"claude", true},
{"2.0.76", true},
{"1.2.3", true},
{"10.20.30", true},
{"bash", false},
{"zsh", false},
{"", false},
{"v2.0.76", false}, // version with 'v' prefix shouldn't match
{"2.0", false}, // incomplete version
}
for _, tt := range tests {
t.Run(tt.cmd, func(t *testing.T) {
// Check if it matches node/claude directly
isKnownCmd := tt.cmd == "node" || tt.cmd == "claude"
// Check version pattern
matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+`, tt.cmd)
got := isKnownCmd || matched
if got != tt.want {
t.Errorf("IsClaudeRunning logic for %q = %v, want %v", tt.cmd, got, tt.want)
}
})
}
}