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

@@ -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)
}
})
}