diff --git a/CHANGELOG.md b/CHANGELOG.md index 03795de3..24cd764f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **New agent presets** - Added Cursor, Auggie (Augment Code), and Sourcegraph AMP as built-in agent presets +- **Centralized agent list** - Agent preset configuration moved to `config.go` for easier maintenance + ## [0.2.2] - 2026-01-07 Rig operational state management, unified agent startup, and extensive stability fixes. diff --git a/README.md b/README.md index 60cac448..6e164654 100644 --- a/README.md +++ b/README.md @@ -274,12 +274,14 @@ gt crew add --rig # Create crew workspace ```bash gt agents # List active agents gt sling # Assign work to agent -gt sling --agent codex # Override runtime for this sling/spawn +gt sling --agent cursor # Override runtime for this sling/spawn gt mayor attach # Start Mayor session -gt mayor start --agent gemini # Run Mayor with a specific agent alias +gt mayor start --agent auggie # Run Mayor with a specific agent alias gt prime # Alternative to mayor attach ``` +**Built-in agent presets**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp` + ### Convoy (Work Tracking) ```bash diff --git a/docs/reference.md b/docs/reference.md index bad44f05..fbf28abb 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -359,7 +359,7 @@ gt config agent remove # Remove custom agent (built-ins protected) gt config default-agent [name] # Get or set town default agent ``` -**Built-in agents**: `claude`, `gemini`, `codex` +**Built-in agents**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp` **Custom agents**: Define per-town in `mayor/town.json`: ```bash diff --git a/internal/beads/beads.go b/internal/beads/beads.go index e6b55d42..edd848b9 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -113,9 +113,9 @@ func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string { // cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory // while preserving tracked files (formulas/, README.md, config.yaml, .gitignore). // This is safe to call even if the directory doesn't exist. -func cleanBeadsRuntimeFiles(beadsDir string) error { +func cleanBeadsRuntimeFiles(beadsDir string) { if _, err := os.Stat(beadsDir); os.IsNotExist(err) { - return nil // Nothing to clean + return // Nothing to clean } // Runtime files/patterns that are gitignored and safe to remove @@ -144,11 +144,9 @@ func cleanBeadsRuntimeFiles(beadsDir string) error { continue // Invalid pattern, skip } for _, match := range matches { - os.RemoveAll(match) // Best effort, ignore errors + _ = os.RemoveAll(match) // Best effort, ignore errors } } - - return nil } // SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads. @@ -192,9 +190,7 @@ func SetupRedirect(townRoot, worktreePath string) error { // Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.) worktreeBeadsDir := filepath.Join(worktreePath, ".beads") - if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil { - return fmt.Errorf("cleaning runtime files: %w", err) - } + cleanBeadsRuntimeFiles(worktreeBeadsDir) // Create .beads directory if it doesn't exist if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil { diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 6a76df6d..1cceb8aa 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -153,7 +153,7 @@ func runConfigAgentList(cmd *cobra.Command, args []string) error { } // Collect all agents - builtInAgents := []string{"claude", "gemini", "codex"} + builtInAgents := config.ListAgentPresets() customAgents := make(map[string]*config.RuntimeConfig) if townSettings.Agents != nil { for name, runtime := range townSettings.Agents { @@ -330,7 +330,7 @@ func runConfigAgentSet(cmd *cobra.Command, args []string) error { fmt.Printf("Agent '%s' set to: %s\n", style.Bold.Render(name), commandLine) // Check if this overrides a built-in - builtInAgents := []string{"claude", "gemini", "codex"} + builtInAgents := config.ListAgentPresets() for _, builtin := range builtInAgents { if name == builtin { fmt.Printf("\n%s\n", style.Dim.Render("(overriding built-in '"+builtin+"' preset)")) @@ -350,7 +350,7 @@ func runConfigAgentRemove(cmd *cobra.Command, args []string) error { } // Check if trying to remove built-in - builtInAgents := []string{"claude", "gemini", "codex"} + builtInAgents := config.ListAgentPresets() for _, builtin := range builtInAgents { if name == builtin { return fmt.Errorf("cannot remove built-in agent '%s' (use 'gt config agent set' to override it)", name) @@ -415,7 +415,7 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error { // Verify agent exists isValid := false - builtInAgents := []string{"claude", "gemini", "codex"} + builtInAgents := config.ListAgentPresets() for _, builtin := range builtInAgents { if name == builtin { isValid = true diff --git a/internal/cmd/costs.go b/internal/cmd/costs.go index a8cd4e18..5945f91a 100644 --- a/internal/cmd/costs.go +++ b/internal/cmd/costs.go @@ -649,9 +649,13 @@ func deriveSessionName() string { return fmt.Sprintf("gt-%s-crew-%s", rig, crew) } - // Town-level roles (mayor, deacon): gt-{town}-{role} - if (role == "mayor" || role == "deacon") && town != "" { - return fmt.Sprintf("gt-%s-%s", town, role) + // Town-level roles (mayor, deacon): gt-{town}-{role} or gt-{role} + if role == "mayor" || role == "deacon" { + if town != "" { + return fmt.Sprintf("gt-%s-%s", town, role) + } + // No town set - use simple gt-{role} pattern + return fmt.Sprintf("gt-%s", role) } // Rig-based roles (witness, refinery): gt-{rig}-{role} @@ -664,12 +668,9 @@ func deriveSessionName() string { // detectCurrentTmuxSession returns the current tmux session name if running inside tmux. // Uses `tmux display-message -p '#S'` which prints the session name. +// Note: We don't check TMUX env var because it may not be inherited when Claude Code +// runs bash commands, even though we are inside a tmux session. func detectCurrentTmuxSession() string { - // Check if we're inside tmux - if os.Getenv("TMUX") == "" { - return "" - } - cmd := exec.Command("tmux", "display-message", "-p", "#S") output, err := cmd.Output() if err != nil { diff --git a/internal/cmd/costs_test.go b/internal/cmd/costs_test.go index 5af03d66..26307e64 100644 --- a/internal/cmd/costs_test.go +++ b/internal/cmd/costs_test.go @@ -61,6 +61,20 @@ func TestDeriveSessionName(t *testing.T) { }, expected: "gt-ai-deacon", }, + { + name: "mayor session without GT_TOWN", + envVars: map[string]string{ + "GT_ROLE": "mayor", + }, + expected: "gt-mayor", + }, + { + name: "deacon session without GT_TOWN", + envVars: map[string]string{ + "GT_ROLE": "deacon", + }, + expected: "gt-deacon", + }, { name: "no env vars", envVars: map[string]string{}, diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index 29fa3385..a652e325 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -29,7 +29,19 @@ func runCrewAt(cmd *cobra.Command, args []string) error { // Try to detect from current directory detected, err := detectCrewFromCwd() if err != nil { - return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at ", err) + // Try to show available crew members if we can detect the rig + hint := "\n\nUsage: gt crew at " + if crewRig != "" { + if mgr, _, mgrErr := getCrewManager(crewRig); mgrErr == nil { + if members, listErr := mgr.List(); listErr == nil && len(members) > 0 { + hint = fmt.Sprintf("\n\nAvailable crew in %s:", crewRig) + for _, m := range members { + hint += fmt.Sprintf("\n %s", m.Name) + } + } + } + } + return fmt.Errorf("could not detect crew workspace from current directory: %w%s", err, hint) } name = detected.crewName if crewRig == "" { diff --git a/internal/cmd/crew_helpers.go b/internal/cmd/crew_helpers.go index d65b0eea..d7724377 100644 --- a/internal/cmd/crew_helpers.go +++ b/internal/cmd/crew_helpers.go @@ -122,7 +122,7 @@ func detectCrewFromCwd() (*crewDetection, error) { // Look for pattern: /crew//... // Minimum: rig, crew, name = 3 parts if len(parts) < 3 { - return nil, fmt.Errorf("not in a crew workspace (path too short)") + return nil, fmt.Errorf("not inside a crew workspace - specify the crew name or cd into a crew directory (e.g., gastown/crew/max)") } rigName := parts[0] diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 57343187..604dc7d1 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -313,6 +313,16 @@ func initTownBeads(townPath string) error { } } + // Configure custom types for Gas Town (agent, role, rig, convoy). + // These were extracted from beads core in v0.46.0 and now require explicit config. + customTypes := "agent,role,rig,convoy,event" + configCmd := exec.Command("bd", "config", "set", "types.custom", customTypes) + configCmd.Dir = townPath + if configOutput, configErr := configCmd.CombinedOutput(); configErr != nil { + // Non-fatal: older beads versions don't need this, newer ones do + fmt.Printf(" %s Could not set custom types: %s\n", style.Dim.Render("⚠"), strings.TrimSpace(string(configOutput))) + } + // Ensure database has repository fingerprint (GH #25). // This is idempotent - safe on both new and legacy (pre-0.17.5) databases. // Without fingerprint, the bd daemon fails to start silently. @@ -321,6 +331,14 @@ func initTownBeads(townPath string) error { fmt.Printf(" %s Could not verify repo fingerprint: %v\n", style.Dim.Render("⚠"), err) } + // Register Gas Town custom types (agent, role, rig, convoy, slot). + // These types are not built into beads core - they must be registered + // before creating agent/role beads. See GH #gt-xyz for context. + if err := ensureCustomTypes(townPath); err != nil { + // Non-fatal but will cause agent bead creation to fail + fmt.Printf(" %s Could not register custom types: %v\n", style.Dim.Render("⚠"), err) + } + return nil } @@ -337,6 +355,20 @@ func ensureRepoFingerprint(beadsPath string) error { return nil } +// ensureCustomTypes registers Gas Town custom issue types with beads. +// Beads core only supports built-in types (bug, feature, task, etc.). +// Gas Town needs custom types: agent, role, rig, convoy, slot. +// This is idempotent - safe to call multiple times. +func ensureCustomTypes(beadsPath string) error { + cmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,slot") + cmd.Dir = beadsPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("bd config set types.custom: %s", strings.TrimSpace(string(output))) + } + return nil +} + // initTownAgentBeads creates town-level agent and role beads using hq- prefix. // This creates: // - hq-mayor, hq-deacon (agent beads for town-level agents) diff --git a/internal/config/agents.go b/internal/config/agents.go index cb8e8f43..44d498ae 100644 --- a/internal/config/agents.go +++ b/internal/config/agents.go @@ -21,12 +21,18 @@ const ( AgentGemini AgentPreset = "gemini" // AgentCodex is OpenAI Codex. AgentCodex AgentPreset = "codex" + // AgentCursor is Cursor Agent. + AgentCursor AgentPreset = "cursor" + // AgentAuggie is Auggie CLI. + AgentAuggie AgentPreset = "auggie" + // AgentAmp is Sourcegraph AMP. + AgentAmp AgentPreset = "amp" ) // AgentPresetInfo contains the configuration details for an agent preset. // This extends the basic RuntimeConfig with agent-specific metadata. type AgentPresetInfo struct { - // Name is the preset identifier (e.g., "claude", "gemini", "codex"). + // Name is the preset identifier (e.g., "claude", "gemini", "codex", "cursor", "auggie", "amp"). Name AgentPreset `json:"name"` // Command is the CLI binary to invoke. @@ -35,6 +41,11 @@ type AgentPresetInfo struct { // Args are the default command-line arguments for autonomous mode. Args []string `json:"args"` + // ProcessNames are the process names to look for when detecting if the agent is running. + // Used by tmux.IsAgentRunning to check pane_current_command. + // E.g., ["node"] for Claude, ["cursor-agent"] for Cursor. + ProcessNames []string `json:"process_names,omitempty"` + // SessionIDEnv is the environment variable for session ID. // Used for resuming sessions across restarts. SessionIDEnv string `json:"session_id_env,omitempty"` @@ -91,6 +102,7 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{ Name: AgentClaude, Command: "claude", Args: []string{"--dangerously-skip-permissions"}, + ProcessNames: []string{"node"}, // Claude runs as Node.js SessionIDEnv: "CLAUDE_SESSION_ID", ResumeFlag: "--resume", ResumeStyle: "flag", @@ -102,6 +114,7 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{ Name: AgentGemini, Command: "gemini", Args: []string{"--approval-mode", "yolo"}, + ProcessNames: []string{"gemini"}, // Gemini CLI binary SessionIDEnv: "GEMINI_SESSION_ID", ResumeFlag: "--resume", ResumeStyle: "flag", @@ -116,6 +129,7 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{ Name: AgentCodex, Command: "codex", Args: []string{"--yolo"}, + ProcessNames: []string{"codex"}, // Codex CLI binary SessionIDEnv: "", // Codex captures from JSONL output ResumeFlag: "resume", ResumeStyle: "subcommand", @@ -126,6 +140,43 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{ OutputFlag: "--json", }, }, + AgentCursor: { + Name: AgentCursor, + Command: "cursor-agent", + Args: []string{"-f"}, // Force mode (YOLO equivalent), -p requires prompt + ProcessNames: []string{"cursor-agent"}, + SessionIDEnv: "", // Uses --resume with chatId directly + ResumeFlag: "--resume", + ResumeStyle: "flag", + SupportsHooks: false, // TODO: verify hooks support + SupportsForkSession: false, + NonInteractive: &NonInteractiveConfig{ + PromptFlag: "-p", + OutputFlag: "--output-format json", + }, + }, + AgentAuggie: { + Name: AgentAuggie, + Command: "auggie", + Args: []string{"--allow-indexing"}, + ProcessNames: []string{"auggie"}, + SessionIDEnv: "", + ResumeFlag: "--resume", + ResumeStyle: "flag", + SupportsHooks: false, + SupportsForkSession: false, + }, + AgentAmp: { + Name: AgentAmp, + Command: "amp", + Args: []string{"--dangerously-allow-all", "--no-ide"}, + ProcessNames: []string{"amp"}, + SessionIDEnv: "", + ResumeFlag: "threads continue", + ResumeStyle: "subcommand", // 'amp threads continue ' + SupportsHooks: false, + SupportsForkSession: false, + }, } // Registry state with proper synchronization. @@ -305,6 +356,18 @@ func GetSessionIDEnvVar(agentName string) string { return info.SessionIDEnv } +// GetProcessNames returns the process names used to detect if an agent is running. +// Used by tmux.IsAgentRunning to check pane_current_command. +// Returns ["node"] for Claude (default) if agent is not found or has no ProcessNames. +func GetProcessNames(agentName string) []string { + info := GetAgentPresetByName(agentName) + if info == nil || len(info.ProcessNames) == 0 { + // Default to Claude's process name for backwards compatibility + return []string{"node"} + } + return info.ProcessNames +} + // MergeWithPreset applies preset defaults to a RuntimeConfig. // User-specified values take precedence over preset defaults. // Returns a new RuntimeConfig without modifying the original. diff --git a/internal/config/agents_test.go b/internal/config/agents_test.go index 9108b143..99305d60 100644 --- a/internal/config/agents_test.go +++ b/internal/config/agents_test.go @@ -9,8 +9,8 @@ import ( ) func TestBuiltinPresets(t *testing.T) { - // Ensure all built-in presets are accessible (E2E tested agents only) - presets := []AgentPreset{AgentClaude, AgentGemini, AgentCodex} + // Ensure all built-in presets are accessible + presets := []AgentPreset{AgentClaude, AgentGemini, AgentCodex, AgentCursor, AgentAuggie, AgentAmp} for _, preset := range presets { info := GetAgentPreset(preset) @@ -22,6 +22,11 @@ func TestBuiltinPresets(t *testing.T) { if info.Command == "" { t.Errorf("preset %s has empty Command", preset) } + + // All presets should have ProcessNames for agent detection + if len(info.ProcessNames) == 0 { + t.Errorf("preset %s has empty ProcessNames", preset) + } } } @@ -34,6 +39,9 @@ func TestGetAgentPresetByName(t *testing.T) { {"claude", AgentClaude, false}, {"gemini", AgentGemini, false}, {"codex", AgentCodex, false}, + {"cursor", AgentCursor, false}, + {"auggie", AgentAuggie, false}, + {"amp", AgentAmp, false}, {"aider", "", true}, // Not built-in, can be added via config {"opencode", "", true}, // Not built-in, can be added via config {"unknown", "", true}, @@ -63,6 +71,9 @@ func TestRuntimeConfigFromPreset(t *testing.T) { {AgentClaude, "claude"}, {AgentGemini, "gemini"}, {AgentCodex, "codex"}, + {AgentCursor, "cursor-agent"}, + {AgentAuggie, "auggie"}, + {AgentAmp, "amp"}, } for _, tt := range tests { @@ -84,6 +95,9 @@ func TestIsKnownPreset(t *testing.T) { {"claude", true}, {"gemini", true}, {"codex", true}, + {"cursor", true}, + {"auggie", true}, + {"amp", true}, {"aider", false}, // Not built-in, can be added via config {"opencode", false}, // Not built-in, can be added via config {"unknown", false}, @@ -286,6 +300,9 @@ func TestSupportsSessionResume(t *testing.T) { {"claude", true}, {"gemini", true}, {"codex", true}, + {"cursor", true}, + {"auggie", true}, + {"amp", true}, {"unknown", false}, } @@ -305,7 +322,10 @@ func TestGetSessionIDEnvVar(t *testing.T) { }{ {"claude", "CLAUDE_SESSION_ID"}, {"gemini", "GEMINI_SESSION_ID"}, - {"codex", ""}, // Codex uses JSONL output instead + {"codex", ""}, // Codex uses JSONL output instead + {"cursor", ""}, // Cursor uses --resume with chatId directly + {"auggie", ""}, // Auggie uses --resume directly + {"amp", ""}, // AMP uses 'threads continue' subcommand {"unknown", ""}, } @@ -317,3 +337,168 @@ func TestGetSessionIDEnvVar(t *testing.T) { }) } } + +func TestGetProcessNames(t *testing.T) { + tests := []struct { + agentName string + want []string + }{ + {"claude", []string{"node"}}, + {"gemini", []string{"gemini"}}, + {"codex", []string{"codex"}}, + {"cursor", []string{"cursor-agent"}}, + {"auggie", []string{"auggie"}}, + {"amp", []string{"amp"}}, + {"unknown", []string{"node"}}, // Falls back to Claude's process + } + + for _, tt := range tests { + t.Run(tt.agentName, func(t *testing.T) { + got := GetProcessNames(tt.agentName) + if len(got) != len(tt.want) { + t.Errorf("GetProcessNames(%s) = %v, want %v", tt.agentName, got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("GetProcessNames(%s)[%d] = %q, want %q", tt.agentName, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestListAgentPresetsMatchesConstants(t *testing.T) { + // Ensure all AgentPreset constants are returned by ListAgentPresets + allConstants := []AgentPreset{AgentClaude, AgentGemini, AgentCodex, AgentCursor, AgentAuggie, AgentAmp} + presets := ListAgentPresets() + + // Convert to map for quick lookup + presetMap := make(map[string]bool) + for _, p := range presets { + presetMap[p] = true + } + + // Verify all constants are in the list + for _, c := range allConstants { + if !presetMap[string(c)] { + t.Errorf("ListAgentPresets() missing constant %q", c) + } + } + + // Verify no empty names + for _, p := range presets { + if p == "" { + t.Error("ListAgentPresets() contains empty string") + } + } +} + +func TestAgentCommandGeneration(t *testing.T) { + // Test full command line generation for each agent + tests := []struct { + preset AgentPreset + wantCommand string + wantContains []string // Args that should be present + }{ + { + preset: AgentClaude, + wantCommand: "claude", + wantContains: []string{"--dangerously-skip-permissions"}, + }, + { + preset: AgentGemini, + wantCommand: "gemini", + wantContains: []string{"--approval-mode", "yolo"}, + }, + { + preset: AgentCodex, + wantCommand: "codex", + wantContains: []string{"--yolo"}, + }, + { + preset: AgentCursor, + wantCommand: "cursor-agent", + wantContains: []string{"-f"}, + }, + { + preset: AgentAuggie, + wantCommand: "auggie", + wantContains: []string{"--allow-indexing"}, + }, + { + preset: AgentAmp, + wantCommand: "amp", + wantContains: []string{"--dangerously-allow-all", "--no-ide"}, + }, + } + + for _, tt := range tests { + t.Run(string(tt.preset), func(t *testing.T) { + rc := RuntimeConfigFromPreset(tt.preset) + if rc == nil { + t.Fatal("RuntimeConfigFromPreset returned nil") + } + + if rc.Command != tt.wantCommand { + t.Errorf("Command = %q, want %q", rc.Command, tt.wantCommand) + } + + // Check required args are present + argsStr := strings.Join(rc.Args, " ") + for _, arg := range tt.wantContains { + found := false + for _, a := range rc.Args { + if a == arg { + found = true + break + } + } + if !found { + t.Errorf("Args %q missing expected %q", argsStr, arg) + } + } + }) + } +} + +func TestCursorAgentPreset(t *testing.T) { + // Verify cursor agent preset is correctly configured + info := GetAgentPreset(AgentCursor) + if info == nil { + t.Fatal("cursor preset not found") + } + + // Check command + if info.Command != "cursor-agent" { + t.Errorf("cursor command = %q, want cursor-agent", info.Command) + } + + // Check YOLO-equivalent flag (-f for force mode) + // Note: -p is for non-interactive mode with prompt, not used for default Args + hasF := false + for _, arg := range info.Args { + if arg == "-f" { + hasF = true + } + } + if !hasF { + t.Error("cursor args missing -f (force/YOLO mode)") + } + + // Check ProcessNames for detection + if len(info.ProcessNames) == 0 { + t.Error("cursor ProcessNames is empty") + } + if info.ProcessNames[0] != "cursor-agent" { + t.Errorf("cursor ProcessNames[0] = %q, want cursor-agent", info.ProcessNames[0]) + } + + // Check resume support + if info.ResumeFlag != "--resume" { + t.Errorf("cursor ResumeFlag = %q, want --resume", info.ResumeFlag) + } + if info.ResumeStyle != "flag" { + t.Errorf("cursor ResumeStyle = %q, want flag", info.ResumeStyle) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index ee6dabfa..3aa0303b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -38,7 +38,7 @@ type TownSettings struct { Version int `json:"version"` // schema version // DefaultAgent is the name of the agent preset to use by default. - // Can be a built-in preset ("claude", "gemini", "codex") + // Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp") // or a custom agent name defined in settings/agents.json. // Default: "claude" DefaultAgent string `json:"default_agent,omitempty"` @@ -190,7 +190,7 @@ type RigSettings struct { Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings (deprecated: use Agent) // Agent selects which agent preset to use for this rig. - // Can be a built-in preset ("claude", "gemini", "codex") + // Can be a built-in preset ("claude", "gemini", "codex", "cursor", "auggie", "amp") // or a custom agent defined in settings/agents.json. // If empty, uses the town's default_agent setting. // Takes precedence over Runtime if both are set. diff --git a/internal/doctor/claude_settings_check.go b/internal/doctor/claude_settings_check.go index fbce040c..54315740 100644 --- a/internal/doctor/claude_settings_check.go +++ b/internal/doctor/claude_settings_check.go @@ -10,6 +10,7 @@ import ( "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/session" + "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/templates" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -477,14 +478,11 @@ func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error { } // Town-root files were inherited by ALL agents via directory traversal. - // Cycle all Gas Town sessions so they pick up the corrected file locations. - // This includes gt-* (rig agents) and hq-* (mayor, deacon). - sessions, _ := t.ListSessions() - for _, sess := range sessions { - if strings.HasPrefix(sess, session.Prefix) || strings.HasPrefix(sess, session.HQPrefix) { - _ = t.KillSession(sess) - } - } + // Warn user to restart agents - don't auto-kill sessions as that's too disruptive, + // especially since deacon runs gt doctor automatically which would create a loop. + // Settings are only read at startup, so running agents already have config loaded. + fmt.Printf("\n %s Town-root settings were moved. Restart agents to pick up new config:\n", style.Warning.Render("⚠")) + fmt.Printf(" gt up --restart\n\n") continue } diff --git a/internal/doctor/claude_settings_check_test.go b/internal/doctor/claude_settings_check_test.go index 278276a4..ca64ecd1 100644 --- a/internal/doctor/claude_settings_check_test.go +++ b/internal/doctor/claude_settings_check_test.go @@ -1016,3 +1016,69 @@ func TestClaudeSettingsCheck_FixMovesCLAUDEmdToMayor(t *testing.T) { t.Error("expected CLAUDE.md to be created at mayor/") } } + +func TestClaudeSettingsCheck_TownRootSettingsWarnsInsteadOfKilling(t *testing.T) { + tmpDir := t.TempDir() + + // Create mayor directory (needed for fix to recreate settings there) + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + + // Create settings.json at town root (wrong location - pollutes all agents) + staleTownRootDir := filepath.Join(tmpDir, ".claude") + if err := os.MkdirAll(staleTownRootDir, 0755); err != nil { + t.Fatal(err) + } + staleTownRootSettings := filepath.Join(staleTownRootDir, "settings.json") + // Create valid settings content + settingsContent := `{ + "env": {"PATH": "/usr/bin"}, + "enabledPlugins": ["claude-code-expert"], + "hooks": { + "SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "gt prime"}]}], + "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "gt handoff"}]}] + } + }` + if err := os.WriteFile(staleTownRootSettings, []byte(settingsContent), 0644); err != nil { + t.Fatal(err) + } + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + // Run to detect + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError for town root settings, got %v", result.Status) + } + + // Verify it's flagged as wrong location + foundWrongLocation := false + for _, d := range result.Details { + if strings.Contains(d, "wrong location") { + foundWrongLocation = true + break + } + } + if !foundWrongLocation { + t.Errorf("expected details to mention wrong location, got %v", result.Details) + } + + // Apply fix - should NOT return error and should NOT kill sessions + // (session killing would require tmux which isn't available in tests) + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify stale file was deleted + if _, err := os.Stat(staleTownRootSettings); !os.IsNotExist(err) { + t.Error("expected settings.json at town root to be deleted") + } + + // Verify .claude directory was cleaned up (best-effort) + if _, err := os.Stat(staleTownRootDir); !os.IsNotExist(err) { + t.Error("expected .claude directory at town root to be deleted") + } +} diff --git a/internal/doctor/rig_check.go b/internal/doctor/rig_check.go index 83c359f6..f8721226 100644 --- a/internal/doctor/rig_check.go +++ b/internal/doctor/rig_check.go @@ -1034,6 +1034,10 @@ func (c *BeadsRedirectCheck) Fix(ctx *CheckContext) error { // Continue - minimal config created } else { _ = output // bd init succeeded + // Configure custom types for Gas Town (beads v0.46.0+) + configCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,event") + configCmd.Dir = rigPath + _, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this } return nil } diff --git a/internal/polecat/manager_test.go b/internal/polecat/manager_test.go index a5b7d317..f5157692 100644 --- a/internal/polecat/manager_test.go +++ b/internal/polecat/manager_test.go @@ -2,6 +2,7 @@ package polecat import ( "os" + "os/exec" "path/filepath" "testing" @@ -144,6 +145,13 @@ func TestAssigneeID(t *testing.T) { func TestGetReturnsWorkingWithoutBeads(t *testing.T) { // When beads is not available, Get should return StateWorking // (assume the polecat is doing something if it exists) + // + // Skip if bd is installed - the test assumes bd is unavailable, but when bd + // is present it queries beads and returns actual state instead of defaulting. + if _, err := exec.LookPath("bd"); err == nil { + t.Skip("skipping: bd is installed, test requires bd to be unavailable") + } + root := t.TempDir() polecatDir := filepath.Join(root, "polecats", "Test") if err := os.MkdirAll(polecatDir, 0755); err != nil { diff --git a/internal/rig/manager.go b/internal/rig/manager.go index a866d741..134b2304 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -359,6 +359,10 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { if output, err := cmd.CombinedOutput(); err != nil { fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output))) } + // Configure custom types for Gas Town (beads v0.46.0+) + configCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,event") + configCmd.Dir = mayorRigPath + _, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this } } } @@ -579,6 +583,14 @@ func (m *Manager) initBeads(rigPath, prefix string) error { } } + // Configure custom types for Gas Town (agent, role, rig, convoy). + // These were extracted from beads core in v0.46.0 and now require explicit config. + configCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,event") + configCmd.Dir = rigPath + configCmd.Env = filteredEnv + // Ignore errors - older beads versions don't need this + _, _ = configCmd.CombinedOutput() + // Ensure database has repository fingerprint (GH #25). // This is idempotent - safe on both new and legacy (pre-0.17.5) databases. // Without fingerprint, the bd daemon fails to start silently. diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 270ca767..3d57814c 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -309,3 +309,119 @@ func TestEnsureSessionFresh_IdempotentOnZombie(t *testing.T) { t.Error("expected session to exist after multiple EnsureSessionFresh calls") } } + +func TestIsAgentRunning(t *testing.T) { + if !hasTmux() { + t.Skip("tmux not installed") + } + + tm := NewTmux() + sessionName := "gt-test-agent-" + t.Name() + + // Clean up any existing session + _ = tm.KillSession(sessionName) + + // Create session (will run default shell) + if err := tm.NewSession(sessionName, ""); err != nil { + t.Fatalf("NewSession: %v", err) + } + defer func() { _ = tm.KillSession(sessionName) }() + + // Get the current pane command (should be bash/zsh/etc) + cmd, err := tm.GetPaneCommand(sessionName) + if err != nil { + t.Fatalf("GetPaneCommand: %v", err) + } + + tests := []struct { + name string + processNames []string + wantRunning bool + }{ + { + name: "empty process list", + processNames: []string{}, + wantRunning: false, + }, + { + name: "matching shell process", + processNames: []string{cmd}, // Current shell + wantRunning: true, + }, + { + name: "claude agent (node) - not running", + processNames: []string{"node"}, + wantRunning: cmd == "node", // Only true if shell happens to be node + }, + { + name: "gemini agent - not running", + processNames: []string{"gemini"}, + wantRunning: cmd == "gemini", + }, + { + name: "cursor agent - not running", + processNames: []string{"cursor-agent"}, + wantRunning: cmd == "cursor-agent", + }, + { + name: "multiple process names with match", + processNames: []string{"nonexistent", cmd, "also-nonexistent"}, + wantRunning: true, + }, + { + name: "multiple process names without match", + processNames: []string{"nonexistent1", "nonexistent2"}, + wantRunning: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tm.IsAgentRunning(sessionName, tt.processNames...) + if got != tt.wantRunning { + t.Errorf("IsAgentRunning(%q, %v) = %v, want %v (current cmd: %q)", + sessionName, tt.processNames, got, tt.wantRunning, cmd) + } + }) + } +} + +func TestIsAgentRunning_NonexistentSession(t *testing.T) { + if !hasTmux() { + t.Skip("tmux not installed") + } + + tm := NewTmux() + + // IsAgentRunning on nonexistent session should return false, not error + got := tm.IsAgentRunning("nonexistent-session-xyz", "node", "gemini", "cursor-agent") + if got { + t.Error("IsAgentRunning on nonexistent session should return false") + } +} + +func TestIsClaudeRunning(t *testing.T) { + if !hasTmux() { + t.Skip("tmux not installed") + } + + tm := NewTmux() + sessionName := "gt-test-claude-" + t.Name() + + // Clean up any existing session + _ = tm.KillSession(sessionName) + + // Create session (will run default shell, not Claude) + if err := tm.NewSession(sessionName, ""); err != nil { + t.Fatalf("NewSession: %v", err) + } + defer func() { _ = tm.KillSession(sessionName) }() + + // IsClaudeRunning should be false (shell is running, not node) + cmd, _ := tm.GetPaneCommand(sessionName) + wantRunning := cmd == "node" + + if got := tm.IsClaudeRunning(sessionName); got != wantRunning { + t.Errorf("IsClaudeRunning() = %v, want %v (pane cmd: %q)", got, wantRunning, cmd) + } +}