From caa88d96c5bba724861dd2e89f0c7b6380e38812 Mon Sep 17 00:00:00 2001 From: julianknutsen Date: Thu, 8 Jan 2026 02:25:15 -0800 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 4.5 --- internal/tmux/tmux.go | 15 +++++++++++++-- internal/tmux/tmux_test.go | 39 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 46f22bd4..78d759da 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -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. diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 3d57814c..13668dcd 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -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) + } + }) + } +}