fix(tmux): resolve claude path for alias installations (#703) (#748)

Fix "Unable to attach mayor" timeout caused by claude being installed
as a shell alias rather than in PATH. Non-interactive shells spawned
by tmux cannot resolve aliases, causing the session to exit immediately.

Changes:
- Add resolveClaudePath() to find claude at ~/.claude/local/claude
- Apply path resolution in RuntimeConfigFromPreset() for claude preset
- Make hasClaudeChild() recursive (now hasClaudeDescendant()) to search
  entire process subtree as defensive improvement
- Update fillRuntimeDefaults() to use DefaultRuntimeConfig() for
  consistent path resolution

Fixes https://github.com/steveyegge/gastown/issues/703

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kartik Shrivastava
2026-01-21 12:02:07 +05:30
committed by John Ogle
parent 858782d657
commit 123c7407fc
4 changed files with 167 additions and 70 deletions

View File

@@ -327,10 +327,18 @@ func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig {
return DefaultRuntimeConfig()
}
return &RuntimeConfig{
rc := &RuntimeConfig{
Command: info.Command,
Args: append([]string(nil), info.Args...), // Copy to avoid mutation
}
// Resolve command path for claude preset (handles alias installations)
// Uses resolveClaudePath() from types.go which finds ~/.claude/local/claude
if preset == AgentClaude && rc.Command == "claude" {
rc.Command = resolveClaudePath()
}
return rc
}
// BuildResumeCommand builds a command to resume an agent session.

View File

@@ -8,6 +8,12 @@ import (
"testing"
)
// isClaudeCmd checks if a command is claude (either "claude" or a path ending in "/claude").
// Note: Named differently from loader_test.go's isClaudeCommand to avoid redeclaration.
func isClaudeCmd(cmd string) bool {
return cmd == "claude" || strings.HasSuffix(cmd, "/claude")
}
func TestBuiltinPresets(t *testing.T) {
t.Parallel()
// Ensure all built-in presets are accessible
@@ -71,7 +77,7 @@ func TestRuntimeConfigFromPreset(t *testing.T) {
preset AgentPreset
wantCommand string
}{
{AgentClaude, "claude"},
{AgentClaude, "claude"}, // Note: claude may resolve to full path
{AgentGemini, "gemini"},
{AgentCodex, "codex"},
{AgentCursor, "cursor-agent"},
@@ -82,7 +88,13 @@ func TestRuntimeConfigFromPreset(t *testing.T) {
for _, tt := range tests {
t.Run(string(tt.preset), func(t *testing.T) {
rc := RuntimeConfigFromPreset(tt.preset)
if rc.Command != tt.wantCommand {
// For claude, command may be full path due to resolveClaudePath
if tt.preset == AgentClaude {
if !isClaudeCmd(rc.Command) {
t.Errorf("RuntimeConfigFromPreset(%s).Command = %v, want claude or path ending in /claude",
tt.preset, rc.Command)
}
} else if rc.Command != tt.wantCommand {
t.Errorf("RuntimeConfigFromPreset(%s).Command = %v, want %v",
tt.preset, rc.Command, tt.wantCommand)
}
@@ -226,8 +238,8 @@ func TestMergeWithPreset(t *testing.T) {
var nilConfig *RuntimeConfig
merged = nilConfig.MergeWithPreset(AgentClaude)
if merged.Command != "claude" {
t.Errorf("nil config merge should get preset command, got %s", merged.Command)
if !isClaudeCmd(merged.Command) {
t.Errorf("nil config merge should get preset command (claude or path), got %s", merged.Command)
}
// Test empty config gets preset defaults
@@ -456,7 +468,12 @@ func TestAgentCommandGeneration(t *testing.T) {
t.Fatal("RuntimeConfigFromPreset returned nil")
}
if rc.Command != tt.wantCommand {
// For claude, command may be full path due to resolveClaudePath
if tt.preset == AgentClaude {
if !isClaudeCmd(rc.Command) {
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
}
} else if rc.Command != tt.wantCommand {
t.Errorf("Command = %q, want %q", rc.Command, tt.wantCommand)
}

View File

@@ -24,6 +24,12 @@ func skipIfAgentBinaryMissing(t *testing.T, agents ...string) {
}
}
// isClaudeCommand checks if a command is claude (either "claude" or a path ending in "/claude").
// This handles the case where resolveClaudePath returns the full path to the claude binary.
func isClaudeCommand(cmd string) bool {
return cmd == "claude" || strings.HasSuffix(cmd, "/claude")
}
func TestTownConfigRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
@@ -821,8 +827,8 @@ func TestRuntimeConfigDefaults(t *testing.T) {
if rc.Provider != "claude" {
t.Errorf("Provider = %q, want %q", rc.Provider, "claude")
}
if rc.Command != "claude" {
t.Errorf("Command = %q, want %q", rc.Command, "claude")
if !isClaudeCommand(rc.Command) {
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
}
if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" {
t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args)
@@ -835,42 +841,58 @@ func TestRuntimeConfigDefaults(t *testing.T) {
func TestRuntimeConfigBuildCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rc *RuntimeConfig
want string
name string
rc *RuntimeConfig
wantContains []string // Parts the command should contain
isClaudeCmd bool // Whether command should be claude (or path to claude)
}{
{
name: "nil config uses defaults",
rc: nil,
want: "claude --dangerously-skip-permissions",
name: "nil config uses defaults",
rc: nil,
wantContains: []string{"--dangerously-skip-permissions"},
isClaudeCmd: true,
},
{
name: "default config",
rc: DefaultRuntimeConfig(),
want: "claude --dangerously-skip-permissions",
name: "default config",
rc: DefaultRuntimeConfig(),
wantContains: []string{"--dangerously-skip-permissions"},
isClaudeCmd: true,
},
{
name: "custom command",
rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}},
want: "aider --no-git",
name: "custom command",
rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}},
wantContains: []string{"aider", "--no-git"},
isClaudeCmd: false,
},
{
name: "multiple args",
rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}},
want: "claude --model opus --no-confirm",
name: "multiple args",
rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}},
wantContains: []string{"--model", "opus", "--no-confirm"},
isClaudeCmd: true,
},
{
name: "empty command uses default",
rc: &RuntimeConfig{Command: "", Args: nil},
want: "claude --dangerously-skip-permissions",
name: "empty command uses default",
rc: &RuntimeConfig{Command: "", Args: nil},
wantContains: []string{"--dangerously-skip-permissions"},
isClaudeCmd: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.rc.BuildCommand()
if got != tt.want {
t.Errorf("BuildCommand() = %q, want %q", got, tt.want)
// Check command contains expected parts
for _, part := range tt.wantContains {
if !strings.Contains(got, part) {
t.Errorf("BuildCommand() = %q, should contain %q", got, part)
}
}
// Check if command starts with claude (or path to claude)
if tt.isClaudeCmd {
parts := strings.Fields(got)
if len(parts) > 0 && !isClaudeCommand(parts[0]) {
t.Errorf("BuildCommand() = %q, command should be claude or path to claude", got)
}
}
})
}
@@ -879,48 +901,64 @@ func TestRuntimeConfigBuildCommand(t *testing.T) {
func TestRuntimeConfigBuildCommandWithPrompt(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rc *RuntimeConfig
prompt string
want string
name string
rc *RuntimeConfig
prompt string
wantContains []string // Parts the command should contain
isClaudeCmd bool // Whether command should be claude (or path to claude)
}{
{
name: "no prompt",
rc: DefaultRuntimeConfig(),
prompt: "",
want: "claude --dangerously-skip-permissions",
name: "no prompt",
rc: DefaultRuntimeConfig(),
prompt: "",
wantContains: []string{"--dangerously-skip-permissions"},
isClaudeCmd: true,
},
{
name: "with prompt",
rc: DefaultRuntimeConfig(),
prompt: "gt prime",
want: `claude --dangerously-skip-permissions "gt prime"`,
name: "with prompt",
rc: DefaultRuntimeConfig(),
prompt: "gt prime",
wantContains: []string{"--dangerously-skip-permissions", `"gt prime"`},
isClaudeCmd: true,
},
{
name: "prompt with quotes",
rc: DefaultRuntimeConfig(),
prompt: `Hello "world"`,
want: `claude --dangerously-skip-permissions "Hello \"world\""`,
name: "prompt with quotes",
rc: DefaultRuntimeConfig(),
prompt: `Hello "world"`,
wantContains: []string{"--dangerously-skip-permissions", `"Hello \"world\""`},
isClaudeCmd: true,
},
{
name: "config initial prompt used if no override",
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
prompt: "",
want: `aider "/help"`,
name: "config initial prompt used if no override",
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
prompt: "",
wantContains: []string{"aider", `"/help"`},
isClaudeCmd: false,
},
{
name: "override takes precedence over config",
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
prompt: "custom prompt",
want: `aider "custom prompt"`,
name: "override takes precedence over config",
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
prompt: "custom prompt",
wantContains: []string{"aider", `"custom prompt"`},
isClaudeCmd: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.rc.BuildCommandWithPrompt(tt.prompt)
if got != tt.want {
t.Errorf("BuildCommandWithPrompt(%q) = %q, want %q", tt.prompt, got, tt.want)
// Check command contains expected parts
for _, part := range tt.wantContains {
if !strings.Contains(got, part) {
t.Errorf("BuildCommandWithPrompt(%q) = %q, should contain %q", tt.prompt, got, part)
}
}
// Check if command starts with claude (or path to claude)
if tt.isClaudeCmd {
parts := strings.Fields(got)
if len(parts) > 0 && !isClaudeCommand(parts[0]) {
t.Errorf("BuildCommandWithPrompt(%q) = %q, command should be claude or path to claude", tt.prompt, got)
}
}
})
}
@@ -1051,11 +1089,13 @@ func TestResolveAgentConfigWithOverride(t *testing.T) {
if name != "claude-haiku" {
t.Fatalf("name = %q, want %q", name, "claude-haiku")
}
if rc.Command != "claude" {
t.Fatalf("rc.Command = %q, want %q", rc.Command, "claude")
if !isClaudeCommand(rc.Command) {
t.Fatalf("rc.Command = %q, want claude or path ending in /claude", rc.Command)
}
if got := rc.BuildCommand(); got != "claude --model haiku --dangerously-skip-permissions" {
t.Fatalf("BuildCommand() = %q, want %q", got, "claude --model haiku --dangerously-skip-permissions")
got := rc.BuildCommand()
// Check command includes expected flags (path to claude may vary)
if !strings.Contains(got, "--model haiku") || !strings.Contains(got, "--dangerously-skip-permissions") {
t.Fatalf("BuildCommand() = %q, want command with --model haiku and --dangerously-skip-permissions", got)
}
})
@@ -1407,8 +1447,9 @@ func TestResolveRoleAgentConfig_FallsBackOnInvalidAgent(t *testing.T) {
// Should fall back to default (claude) when agent is invalid
rc := ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, rigPath)
if rc.Command != "claude" {
t.Errorf("expected fallback to claude, got: %s", rc.Command)
// Command can be "claude" or full path to claude
if rc.Command != "claude" && !strings.HasSuffix(rc.Command, "/claude") {
t.Errorf("expected fallback to claude or path ending in /claude, got: %s", rc.Command)
}
}
@@ -1490,8 +1531,8 @@ func TestLoadRuntimeConfigFallsBackToDefaults(t *testing.T) {
t.Parallel()
// Non-existent path should use defaults
rc := LoadRuntimeConfig("/nonexistent/path")
if rc.Command != "claude" {
t.Errorf("Command = %q, want %q (default)", rc.Command, "claude")
if !isClaudeCommand(rc.Command) {
t.Errorf("Command = %q, want claude or path ending in /claude (default)", rc.Command)
}
}
@@ -1964,7 +2005,12 @@ func TestLookupAgentConfigWithRigSettings(t *testing.T) {
t.Errorf("lookupAgentConfig(%s) returned nil", tt.name)
}
if rc.Command != tt.expectedCommand {
// For claude commands, allow either "claude" or path ending in /claude
if tt.expectedCommand == "claude" {
if !isClaudeCommand(rc.Command) {
t.Errorf("lookupAgentConfig(%s).Command = %s, want claude or path ending in /claude", tt.name, rc.Command)
}
} else if rc.Command != tt.expectedCommand {
t.Errorf("lookupAgentConfig(%s).Command = %s, want %s", tt.name, rc.Command, tt.expectedCommand)
}
})
@@ -2008,8 +2054,8 @@ func TestResolveRoleAgentConfig(t *testing.T) {
t.Run("rig RoleAgents overrides town RoleAgents", func(t *testing.T) {
rc := ResolveRoleAgentConfig("witness", townRoot, rigPath)
// Should get claude-haiku from rig's RoleAgents
if rc.Command != "claude" {
t.Errorf("Command = %q, want %q", rc.Command, "claude")
if !isClaudeCommand(rc.Command) {
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
}
cmd := rc.BuildCommand()
if !strings.Contains(cmd, "--model haiku") {
@@ -2035,9 +2081,9 @@ func TestResolveRoleAgentConfig(t *testing.T) {
t.Run("town-level role (no rigPath) uses town RoleAgents", func(t *testing.T) {
rc := ResolveRoleAgentConfig("mayor", townRoot, "")
// mayor is in town's RoleAgents
if rc.Command != "claude" {
t.Errorf("Command = %q, want %q", rc.Command, "claude")
// mayor is in town's RoleAgents - command can be "claude" or full path to claude
if rc.Command != "claude" && !strings.HasSuffix(rc.Command, "/claude") {
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
}
})
}

View File

@@ -2,8 +2,9 @@
package config
import (
"path/filepath"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
@@ -469,10 +470,35 @@ func defaultRuntimeCommand(provider string) string {
case "generic":
return ""
default:
return "claude"
return resolveClaudePath()
}
}
// resolveClaudePath finds the claude binary, checking PATH first then common installation locations.
// This handles the case where claude is installed as an alias (not in PATH) which doesn't work
// in non-interactive shells spawned by tmux.
func resolveClaudePath() string {
// First, try to find claude in PATH
if path, err := exec.LookPath("claude"); err == nil {
return path
}
// Check common Claude Code installation locations
home, err := os.UserHomeDir()
if err != nil {
return "claude" // Fall back to bare command
}
// Standard Claude Code installation path
claudePath := filepath.Join(home, ".claude", "local", "claude")
if _, err := os.Stat(claudePath); err == nil {
return claudePath
}
// Fall back to bare command (might work if PATH is set differently in tmux)
return "claude"
}
func defaultRuntimeArgs(provider string) []string {
switch provider {
case "claude":