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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user