From 9b5c88979569dcc0f72b2d2191121cf338053783 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 7 Jan 2026 18:20:09 -0800 Subject: [PATCH 1/7] fix(costs): detect tmux session without TMUX env var The TMUX environment variable is not inherited when Claude Code runs bash commands, even though we are inside a tmux session. This caused the Stop hook's 'gt costs record' to fail with: Error: --session flag required Fix: Remove the early return that checked TMUX env var. The tmux display-message command will naturally fail if we're not in tmux, so the check was unnecessary and harmful. Fixes: hq-to0lr Co-Authored-By: Claude Opus 4.5 --- internal/cmd/costs.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/cmd/costs.go b/internal/cmd/costs.go index a8cd4e18..7568055e 100644 --- a/internal/cmd/costs.go +++ b/internal/cmd/costs.go @@ -664,12 +664,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 { From ffeff97d9f1854ff68f2660c9c8c12515d378f08 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 7 Jan 2026 18:24:38 -0800 Subject: [PATCH 2/7] fix(crew): improve error message when not in crew workspace - Show clearer error explaining user needs to specify crew name or cd into crew dir - When --rig is specified, list available crew members in that rig Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew_at.go | 14 +++++++++++++- internal/cmd/crew_helpers.go | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) 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] From 6209a49d545cadfa4712e96c8580c4937a46d340 Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Wed, 7 Jan 2026 19:17:28 -0800 Subject: [PATCH 3/7] fix(costs): derive session name for mayor/deacon without GT_TOWN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error: Ran 1 stop hook ⎿ Stop hook error: Failed with non-blocking status code: Error: --session flag required (or set GT_SESSION env var, or GT_RIG/GT_ROLE) Usage: gt costs record [flags] deriveSessionName() now falls back to gt-{role} when GT_ROLE is mayor or deacon but GT_TOWN is not set. Previously this case returned empty string, causing the Stop hook to fail. --- internal/cmd/costs.go | 10 +++++++--- internal/cmd/costs_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/cmd/costs.go b/internal/cmd/costs.go index 7568055e..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} 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{}, From 585c20464824819763bae7af7c41dae26c0b6c80 Mon Sep 17 00:00:00 2001 From: tajquitgenius <123477755+tajquitgenius@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:17:39 -0500 Subject: [PATCH 4/7] fix: Register custom beads types during install (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During `gt install`, the beads database is initialized but Gas Town's custom issue types (agent, role, rig, convoy, slot) were not being registered. This caused subsequent agent bead creation to fail with "invalid issue type: agent" errors. The fix adds `bd config set types.custom "agent,role,rig,convoy,slot"` after `bd init` completes. This is idempotent and safe to run multiple times. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- internal/cmd/install.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 57343187..31d3e050 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -321,6 +321,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 +345,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) From 92042d679c922b1be34fc3e27964f1f20c7abca6 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Wed, 7 Jan 2026 20:35:06 -0800 Subject: [PATCH 5/7] feat: Add Cursor, Auggie, and Sourcegraph AMP agent presets (#247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Cursor Agent as compatible agent for Gas Town Add AgentCursor preset with ProcessNames field for multi-agent detection: - AgentCursor preset: cursor-agent -p -f (headless + force mode) - ProcessNames field on AgentPresetInfo for agent detection - IsAgentRunning(session, processNames) in tmux package - GetProcessNames(agentName) helper function Closes: ga-vwr 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor: centralize agent preset list in config.go Replace hardcoded ["claude", "gemini", "codex"] arrays with calls to config.ListAgentPresets() to dynamically include all registered agents. This fixes cursor agent not appearing in `gt config agent list` and ensures new agent presets are automatically included everywhere. Also updated doc comments to include "cursor" in example lists. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * test: add comprehensive agent client tests Add tests for agent detection and command generation: - TestIsAgentRunning: validates process name detection for all agents (claude/node, gemini, codex, cursor-agent) - TestIsAgentRunning_NonexistentSession: edge case handling - TestIsClaudeRunning: backwards compatibility wrapper - TestListAgentPresetsMatchesConstants: ensures ListAgentPresets() returns all AgentPreset constants - TestAgentCommandGeneration: validates full command line generation for all supported agents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat: add Auggie agent, fix Cursor interactive mode Add Auggie CLI as supported agent: - Command: auggie - Args: --allow-indexing - Supports session resume via --resume flag Fix Cursor agent configuration: - Remove -p flag (requires prompt, breaks interactive mode) - Clear SessionIDEnv (cursor uses --resume with chatId directly) - Keep -f flag for force/YOLO mode Updated all test cases for both agents. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(agents): add Sourcegraph AMP as agent preset Add AgentAmp constant and builtinPresets entry for Sourcegraph AMP CLI. Configuration: - Command: amp - Args: --dangerously-allow-all --no-ide - ResumeStyle: subcommand (amp threads continue ) - ProcessNames: amp Closes: ga-guq 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: lint error in cleanBeadsRuntimeFiles Change function to not return error (was always nil). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: beads v0.46.0 compatibility and test fixes - Add custom types config (agent,role,rig,convoy,event) after bd init calls - Fix tmux_test.go to use variadic IsAgentRunning signature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * docs: update agent documentation for new presets - README.md: Update agent examples to show cursor/auggie, add built-in presets list - docs/reference.md: Add cursor, auggie, amp to built-in agents list - CHANGELOG.md: Add entry for new agent presets under [Unreleased] Addresses PR #247 review feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- CHANGELOG.md | 4 + README.md | 6 +- docs/reference.md | 2 +- internal/beads/beads.go | 12 +-- internal/cmd/config.go | 8 +- internal/cmd/install.go | 10 ++ internal/config/agents.go | 65 ++++++++++- internal/config/agents_test.go | 191 ++++++++++++++++++++++++++++++++- internal/config/types.go | 4 +- internal/doctor/rig_check.go | 4 + internal/rig/manager.go | 12 +++ internal/tmux/tmux_test.go | 116 ++++++++++++++++++++ 12 files changed, 413 insertions(+), 21 deletions(-) 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/install.go b/internal/cmd/install.go index 31d3e050..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. 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/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/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) + } +} From ffa8dd56cbcf77346be2e224f1734e51cc082141 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 7 Jan 2026 20:41:34 -0800 Subject: [PATCH 6/7] test(polecat): skip beads-unavailable test when bd is installed (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestGetReturnsWorkingWithoutBeads assumes bd is not available and expects state to default to StateWorking. When bd is installed, it actually queries beads and returns the real state, causing the test to fail. Skip the test when bd is detected to avoid environment-dependent failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- internal/polecat/manager_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) 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 { From 117b91b87f4b7dc6d688bac0d4a2dbc28b63864f Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 7 Jan 2026 20:41:57 -0800 Subject: [PATCH 7/7] fix(doctor): warn instead of killing sessions for stale town-root settings (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When gt doctor --fix detects stale Claude settings at town root, it was automatically killing ALL Gas Town sessions (gt-* and hq-*). This is too disruptive because: 1. Deacon runs gt doctor automatically, creating a restart loop 2. Active crew/polecat work could be lost mid-task 3. Settings are only read at startup, so running agents already have the config loaded in memory Instead, warn the user and tell them to restart agents manually: "Town-root settings were moved. Restart agents to pick up new config: gt up --restart" Addresses PR #239 feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- internal/doctor/claude_settings_check.go | 14 ++-- internal/doctor/claude_settings_check_test.go | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) 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") + } +}