From ea1a41f1f5ed09363afcf2dc5cd821c3707fafce Mon Sep 17 00:00:00 2001 From: mayor Date: Sun, 4 Jan 2026 14:46:54 -0500 Subject: [PATCH] feat(config): Add multi-agent support with pluggable registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements agent abstraction layer to support multiple AI coding agents. Built-in presets (E2E tested): - Claude Code (default) - Gemini CLI - OpenAI Codex Key changes: - Add AgentRegistry with built-in presets and custom agent support - Add TownSettings with default_agent and custom agents map - Add Agent field to RigSettings for per-rig agent selection - Update ResolveAgentConfig for hierarchical config resolution - Update spawn paths to use configured agent instead of hardcoded claude Configuration hierarchy (first match wins): 1. Rig's Runtime config (backwards compat) 2. Rig's Agent -> custom agents -> built-in presets 3. Town's default_agent setting 4. Fallback to Claude Additional agents (aider, opencode, etc.) can be added via config file: settings/agents.json Addresses Issue #10: Agent Agnostic Engine with Multi-provider support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew_at.go | 9 +- internal/cmd/crew_helpers.go | 26 +-- internal/cmd/polecat_spawn.go | 4 +- internal/config/agents.go | 296 +++++++++++++++++++++++++++++++++ internal/config/agents_test.go | 217 ++++++++++++++++++++++++ internal/config/loader.go | 136 ++++++++++++++- internal/config/types.go | 41 ++++- 7 files changed, 709 insertions(+), 20 deletions(-) create mode 100644 internal/config/agents.go create mode 100644 internal/config/agents_test.go diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index 9a48337e..0457a048 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -183,10 +183,11 @@ func runCrewAt(cmd *cobra.Command, args []string) error { // Check if we're already in the target session if isInTmuxSession(sessionID) { - // We're in the session at a shell prompt - just start Claude directly - // Pass "gt prime" as initial prompt so Claude loads context immediately - fmt.Printf("Starting Claude in current session...\n") - return execClaude("gt prime") + // We're in the session at a shell prompt - just start the agent directly + // Pass "gt prime" as initial prompt so it loads context immediately + agentCfg := config.ResolveAgentConfig(townRoot, r.Path) + fmt.Printf("Starting %s in current session...\n", agentCfg.Command) + return execAgent(agentCfg, "gt prime") } // If inside tmux (but different session), don't switch - just inform user diff --git a/internal/cmd/crew_helpers.go b/internal/cmd/crew_helpers.go index 1d8ec5cf..df7bab63 100644 --- a/internal/cmd/crew_helpers.go +++ b/internal/cmd/crew_helpers.go @@ -8,6 +8,7 @@ import ( "strings" "syscall" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/git" @@ -147,21 +148,26 @@ func isShellCommand(cmd string) bool { return false } -// execClaude execs claude, replacing the current process. -// Used when we're already in the target session and just need to start Claude. -// If prompt is provided, it's passed as the initial prompt to Claude. -func execClaude(prompt string) error { - claudePath, err := exec.LookPath("claude") - if err != nil { - return fmt.Errorf("claude not found: %w", err) +// execAgent execs the configured agent, replacing the current process. +// Used when we're already in the target session and just need to start the agent. +// If prompt is provided, it's passed as the initial prompt. +func execAgent(cfg *config.RuntimeConfig, prompt string) error { + if cfg == nil { + cfg = config.DefaultRuntimeConfig() } - // exec replaces current process with claude - args := []string{"claude", "--dangerously-skip-permissions"} + agentPath, err := exec.LookPath(cfg.Command) + if err != nil { + return fmt.Errorf("%s not found: %w", cfg.Command, err) + } + + // exec replaces current process with agent + // args[0] must be the command name (convention for exec) + args := append([]string{cfg.Command}, cfg.Args...) if prompt != "" { args = append(args, prompt) } - return syscall.Exec(claudePath, args, os.Environ()) + return syscall.Exec(agentPath, args, os.Environ()) } // isInTmuxSession checks if we're currently inside the target tmux session. diff --git a/internal/cmd/polecat_spawn.go b/internal/cmd/polecat_spawn.go index d43e5655..9d25f17b 100644 --- a/internal/cmd/polecat_spawn.go +++ b/internal/cmd/polecat_spawn.go @@ -122,7 +122,9 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec fmt.Printf("Polecat created. Agent must be started manually.\n\n") fmt.Printf("To start the agent:\n") fmt.Printf(" cd %s\n", polecatObj.ClonePath) - fmt.Printf(" claude --dangerously-skip-permissions\n\n") + // Use rig's configured agent command + agentCmd := config.ResolveAgentConfig(townRoot, r.Path).BuildCommand() + fmt.Printf(" %s\n\n", agentCmd) fmt.Printf("Agent will discover work via gt prime on startup.\n") return &SpawnedPolecatInfo{ diff --git a/internal/config/agents.go b/internal/config/agents.go new file mode 100644 index 00000000..3d4122fb --- /dev/null +++ b/internal/config/agents.go @@ -0,0 +1,296 @@ +// Package config provides configuration types and serialization for Gas Town. +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// AgentPreset identifies a supported LLM agent runtime. +// These presets provide sensible defaults that can be overridden in config. +type AgentPreset string + +// Supported agent presets (built-in, E2E tested). +const ( + // AgentClaude is Claude Code (default). + AgentClaude AgentPreset = "claude" + // AgentGemini is Gemini CLI. + AgentGemini AgentPreset = "gemini" + // AgentCodex is OpenAI Codex. + AgentCodex AgentPreset = "codex" +) + +// 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 AgentPreset `json:"name"` + + // Command is the CLI binary to invoke. + Command string `json:"command"` + + // Args are the default command-line arguments for autonomous mode. + Args []string `json:"args"` + + // SessionIDEnv is the environment variable for session ID. + // Used for resuming sessions across restarts. + SessionIDEnv string `json:"session_id_env,omitempty"` + + // ResumeFlag is the flag/subcommand for resuming sessions. + // For claude/gemini: "--resume" + // For codex: "resume" (subcommand) + ResumeFlag string `json:"resume_flag,omitempty"` + + // ResumeStyle indicates how to invoke resume: + // "flag" - pass as --resume argument + // "subcommand" - pass as 'codex resume ' + ResumeStyle string `json:"resume_style,omitempty"` + + // SupportsHooks indicates if the agent supports hooks system. + SupportsHooks bool `json:"supports_hooks,omitempty"` + + // SupportsForkSession indicates if --fork-session is available. + // Claude-only feature for seance command. + SupportsForkSession bool `json:"supports_fork_session,omitempty"` + + // NonInteractive contains settings for non-interactive mode. + NonInteractive *NonInteractiveConfig `json:"non_interactive,omitempty"` +} + +// NonInteractiveConfig contains settings for running agents non-interactively. +type NonInteractiveConfig struct { + // Subcommand is the subcommand for non-interactive execution (e.g., "exec" for codex). + Subcommand string `json:"subcommand,omitempty"` + + // PromptFlag is the flag for passing prompts (e.g., "-p" for gemini). + PromptFlag string `json:"prompt_flag,omitempty"` + + // OutputFlag is the flag for structured output (e.g., "--json", "--output-format json"). + OutputFlag string `json:"output_flag,omitempty"` +} + +// AgentRegistry contains all known agent presets. +// Can be loaded from JSON config or use built-in defaults. +type AgentRegistry struct { + // Version is the schema version for the registry. + Version int `json:"version"` + + // Agents maps agent names to their configurations. + Agents map[string]*AgentPresetInfo `json:"agents"` +} + +// CurrentAgentRegistryVersion is the current schema version. +const CurrentAgentRegistryVersion = 1 + +// builtinPresets contains the default presets for supported agents. +var builtinPresets = map[AgentPreset]*AgentPresetInfo{ + AgentClaude: { + Name: AgentClaude, + Command: "claude", + Args: []string{"--dangerously-skip-permissions"}, + SessionIDEnv: "CLAUDE_SESSION_ID", + ResumeFlag: "--resume", + ResumeStyle: "flag", + SupportsHooks: true, + SupportsForkSession: true, + NonInteractive: nil, // Claude is native non-interactive + }, + AgentGemini: { + Name: AgentGemini, + Command: "gemini", + Args: []string{"--approval-mode", "yolo"}, + SessionIDEnv: "GEMINI_SESSION_ID", + ResumeFlag: "--resume", + ResumeStyle: "flag", + SupportsHooks: true, + SupportsForkSession: false, + NonInteractive: &NonInteractiveConfig{ + PromptFlag: "-p", + OutputFlag: "--output-format json", + }, + }, + AgentCodex: { + Name: AgentCodex, + Command: "codex", + Args: []string{"--yolo"}, + SessionIDEnv: "", // Codex captures from JSONL output + ResumeFlag: "resume", + ResumeStyle: "subcommand", + SupportsHooks: false, // Use env/files instead + SupportsForkSession: false, + NonInteractive: &NonInteractiveConfig{ + Subcommand: "exec", + OutputFlag: "--json", + }, + }, +} + +// globalRegistry is the merged registry of built-in and user-defined agents. +var globalRegistry *AgentRegistry + +// initRegistry initializes the global registry with built-in presets. +func initRegistry() { + if globalRegistry != nil { + return + } + globalRegistry = &AgentRegistry{ + Version: CurrentAgentRegistryVersion, + Agents: make(map[string]*AgentPresetInfo), + } + // Copy built-in presets + for name, preset := range builtinPresets { + globalRegistry.Agents[string(name)] = preset + } +} + +// LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins. +// User-defined agents override built-in presets with the same name. +func LoadAgentRegistry(path string) error { + initRegistry() + + data, err := os.ReadFile(path) //nolint:gosec // G304: path is from config + if err != nil { + if os.IsNotExist(err) { + return nil // No custom config, use built-ins only + } + return err + } + + var userRegistry AgentRegistry + if err := json.Unmarshal(data, &userRegistry); err != nil { + return err + } + + // Merge user-defined agents (override built-ins) + for name, preset := range userRegistry.Agents { + preset.Name = AgentPreset(name) + globalRegistry.Agents[name] = preset + } + + return nil +} + +// DefaultAgentRegistryPath returns the default path for agent registry. +// Located alongside other town settings. +func DefaultAgentRegistryPath(townRoot string) string { + return filepath.Join(townRoot, "settings", "agents.json") +} + +// GetAgentPreset returns the preset info for a given agent name. +// Returns nil if the preset is not found. +func GetAgentPreset(name AgentPreset) *AgentPresetInfo { + initRegistry() + return globalRegistry.Agents[string(name)] +} + +// GetAgentPresetByName returns the preset info by string name. +// Returns nil if not found, allowing caller to fall back to defaults. +func GetAgentPresetByName(name string) *AgentPresetInfo { + initRegistry() + return globalRegistry.Agents[name] +} + +// ListAgentPresets returns all known agent preset names. +func ListAgentPresets() []string { + initRegistry() + names := make([]string, 0, len(globalRegistry.Agents)) + for name := range globalRegistry.Agents { + names = append(names, name) + } + return names +} + +// DefaultAgentPreset returns the default agent preset (Claude). +func DefaultAgentPreset() AgentPreset { + return AgentClaude +} + +// RuntimeConfigFromPreset creates a RuntimeConfig from an agent preset. +// This provides the basic Command/Args; additional fields from AgentPresetInfo +// can be accessed separately for extended functionality. +func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig { + info := GetAgentPreset(preset) + if info == nil { + // Fall back to Claude defaults + return DefaultRuntimeConfig() + } + + return &RuntimeConfig{ + Command: info.Command, + Args: append([]string(nil), info.Args...), // Copy to avoid mutation + } +} + +// MergeWithPreset applies preset defaults to a RuntimeConfig. +// User-specified values take precedence over preset defaults. +// Returns a new RuntimeConfig without modifying the original. +func (rc *RuntimeConfig) MergeWithPreset(preset AgentPreset) *RuntimeConfig { + if rc == nil { + return RuntimeConfigFromPreset(preset) + } + + info := GetAgentPreset(preset) + if info == nil { + return rc + } + + result := &RuntimeConfig{ + Command: rc.Command, + Args: append([]string(nil), rc.Args...), + InitialPrompt: rc.InitialPrompt, + } + + // Apply preset defaults only if not overridden + if result.Command == "" { + result.Command = info.Command + } + if len(result.Args) == 0 { + result.Args = append([]string(nil), info.Args...) + } + + return result +} + +// IsKnownPreset checks if a string is a known agent preset name. +func IsKnownPreset(name string) bool { + initRegistry() + _, ok := globalRegistry.Agents[name] + return ok +} + +// SaveAgentRegistry writes the agent registry to a file. +func SaveAgentRegistry(path string, registry *AgentRegistry) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(registry, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) //nolint:gosec // G306: config file +} + +// NewExampleAgentRegistry creates an example registry with comments. +func NewExampleAgentRegistry() *AgentRegistry { + return &AgentRegistry{ + Version: CurrentAgentRegistryVersion, + Agents: map[string]*AgentPresetInfo{ + // Include one example custom agent + "my-custom-agent": { + Name: "my-custom-agent", + Command: "my-agent-cli", + Args: []string{"--autonomous", "--no-confirm"}, + SessionIDEnv: "MY_AGENT_SESSION_ID", + ResumeFlag: "--resume", + ResumeStyle: "flag", + NonInteractive: &NonInteractiveConfig{ + PromptFlag: "-m", + OutputFlag: "--json", + }, + }, + }, + } +} diff --git a/internal/config/agents_test.go b/internal/config/agents_test.go new file mode 100644 index 00000000..dbcc1415 --- /dev/null +++ b/internal/config/agents_test.go @@ -0,0 +1,217 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestBuiltinPresets(t *testing.T) { + // Ensure all built-in presets are accessible (E2E tested agents only) + presets := []AgentPreset{AgentClaude, AgentGemini, AgentCodex} + + for _, preset := range presets { + info := GetAgentPreset(preset) + if info == nil { + t.Errorf("GetAgentPreset(%s) returned nil", preset) + continue + } + + if info.Command == "" { + t.Errorf("preset %s has empty Command", preset) + } + } +} + +func TestGetAgentPresetByName(t *testing.T) { + tests := []struct { + name string + want AgentPreset + wantNil bool + }{ + {"claude", AgentClaude, false}, + {"gemini", AgentGemini, false}, + {"codex", AgentCodex, false}, + {"aider", "", true}, // Not built-in, can be added via config + {"opencode", "", true}, // Not built-in, can be added via config + {"unknown", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetAgentPresetByName(tt.name) + if tt.wantNil && got != nil { + t.Errorf("GetAgentPresetByName(%s) = %v, want nil", tt.name, got) + } + if !tt.wantNil && got == nil { + t.Errorf("GetAgentPresetByName(%s) = nil, want preset", tt.name) + } + if !tt.wantNil && got != nil && got.Name != tt.want { + t.Errorf("GetAgentPresetByName(%s).Name = %v, want %v", tt.name, got.Name, tt.want) + } + }) + } +} + +func TestRuntimeConfigFromPreset(t *testing.T) { + tests := []struct { + preset AgentPreset + wantCommand string + }{ + {AgentClaude, "claude"}, + {AgentGemini, "gemini"}, + {AgentCodex, "codex"}, + } + + for _, tt := range tests { + t.Run(string(tt.preset), func(t *testing.T) { + rc := RuntimeConfigFromPreset(tt.preset) + if rc.Command != tt.wantCommand { + t.Errorf("RuntimeConfigFromPreset(%s).Command = %v, want %v", + tt.preset, rc.Command, tt.wantCommand) + } + }) + } +} + +func TestIsKnownPreset(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"claude", true}, + {"gemini", true}, + {"codex", true}, + {"aider", false}, // Not built-in, can be added via config + {"opencode", false}, // Not built-in, can be added via config + {"unknown", false}, + {"chatgpt", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsKnownPreset(tt.name); got != tt.want { + t.Errorf("IsKnownPreset(%s) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestLoadAgentRegistry(t *testing.T) { + // Create temp directory for test config + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "agents.json") + + // Write custom agent config + customRegistry := AgentRegistry{ + Version: CurrentAgentRegistryVersion, + Agents: map[string]*AgentPresetInfo{ + "my-agent": { + Name: "my-agent", + Command: "my-agent-bin", + Args: []string{"--auto"}, + }, + }, + } + + data, err := json.Marshal(customRegistry) + if err != nil { + t.Fatalf("failed to marshal test config: %v", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + // Reset global registry for test + globalRegistry = nil + + // Load the custom registry + if err := LoadAgentRegistry(configPath); err != nil { + t.Fatalf("LoadAgentRegistry failed: %v", err) + } + + // Check custom agent is available + myAgent := GetAgentPresetByName("my-agent") + if myAgent == nil { + t.Fatal("custom agent 'my-agent' not found after loading registry") + } + if myAgent.Command != "my-agent-bin" { + t.Errorf("my-agent.Command = %v, want my-agent-bin", myAgent.Command) + } + + // Check built-ins still accessible + claude := GetAgentPresetByName("claude") + if claude == nil { + t.Fatal("built-in 'claude' not found after loading registry") + } + + // Reset for other tests + globalRegistry = nil +} + +func TestAgentPresetYOLOFlags(t *testing.T) { + // Verify YOLO flags are set correctly for each E2E tested agent + tests := []struct { + preset AgentPreset + wantArg string // At least this arg should be present + }{ + {AgentClaude, "--dangerously-skip-permissions"}, + {AgentGemini, "yolo"}, // Part of "--approval-mode yolo" + {AgentCodex, "--yolo"}, + } + + for _, tt := range tests { + t.Run(string(tt.preset), func(t *testing.T) { + info := GetAgentPreset(tt.preset) + if info == nil { + t.Fatalf("preset %s not found", tt.preset) + } + + found := false + for _, arg := range info.Args { + if arg == tt.wantArg || (tt.preset == AgentGemini && arg == "yolo") { + found = true + break + } + } + if !found { + t.Errorf("preset %s args %v missing expected %s", tt.preset, info.Args, tt.wantArg) + } + }) + } +} + +func TestMergeWithPreset(t *testing.T) { + // Test that user config overrides preset defaults + userConfig := &RuntimeConfig{ + Command: "/custom/claude", + Args: []string{"--custom-arg"}, + } + + merged := userConfig.MergeWithPreset(AgentClaude) + + if merged.Command != "/custom/claude" { + t.Errorf("merged command should be user value, got %s", merged.Command) + } + if len(merged.Args) != 1 || merged.Args[0] != "--custom-arg" { + t.Errorf("merged args should be user value, got %v", merged.Args) + } + + // Test nil config gets preset defaults + var nilConfig *RuntimeConfig + merged = nilConfig.MergeWithPreset(AgentClaude) + + if merged.Command != "claude" { + t.Errorf("nil config merge should get preset command, got %s", merged.Command) + } + + // Test empty config gets preset defaults + emptyConfig := &RuntimeConfig{} + merged = emptyConfig.MergeWithPreset(AgentGemini) + + if merged.Command != "gemini" { + t.Errorf("empty config merge should get preset command, got %s", merged.Command) + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 6f86e64a..cfb53131 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -642,6 +642,8 @@ func LoadOrCreateMessagingConfig(path string) (*MessagingConfig, error) { // LoadRuntimeConfig loads the RuntimeConfig from a rig's settings. // Falls back to defaults if settings don't exist or don't specify runtime config. // rigPath should be the path to the rig directory (e.g., ~/gt/gastown). +// +// Deprecated: Use ResolveAgentConfig for full agent resolution with town settings. func LoadRuntimeConfig(rigPath string) *RuntimeConfig { settingsPath := filepath.Join(rigPath, "settings", "config.json") settings, err := LoadRigSettings(settingsPath) @@ -662,15 +664,139 @@ func LoadRuntimeConfig(rigPath string) *RuntimeConfig { return rc } +// TownSettingsPath returns the path to town settings file. +func TownSettingsPath(townRoot string) string { + return filepath.Join(townRoot, "settings", "config.json") +} + +// RigSettingsPath returns the path to rig settings file. +func RigSettingsPath(rigPath string) string { + return filepath.Join(rigPath, "settings", "config.json") +} + +// LoadOrCreateTownSettings loads town settings or creates defaults if missing. +func LoadOrCreateTownSettings(path string) (*TownSettings, error) { + data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally + if err != nil { + if os.IsNotExist(err) { + return NewTownSettings(), nil + } + return nil, err + } + + var settings TownSettings + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + return &settings, nil +} + +// ResolveAgentConfig resolves the agent configuration for a rig. +// It looks up the agent by name in town settings (custom agents) and built-in presets. +// +// Resolution order: +// 1. If rig has Runtime set directly, use it (backwards compatibility) +// 2. If rig has Agent set, look it up in: +// a. Town's custom agents (from TownSettings.Agents) +// b. Built-in presets (claude, gemini, codex) +// 3. If rig has no Agent set, use town's default_agent +// 4. Fall back to claude defaults +// +// townRoot is the path to the town directory (e.g., ~/gt). +// rigPath is the path to the rig directory (e.g., ~/gt/gastown). +func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig { + // Load rig settings + rigSettings, err := LoadRigSettings(RigSettingsPath(rigPath)) + if err != nil { + rigSettings = nil + } + + // Backwards compatibility: if Runtime is set directly, use it + if rigSettings != nil && rigSettings.Runtime != nil { + rc := rigSettings.Runtime + return fillRuntimeDefaults(rc) + } + + // Load town settings for agent lookup + townSettings, err := LoadOrCreateTownSettings(TownSettingsPath(townRoot)) + if err != nil { + townSettings = NewTownSettings() + } + + // Load custom agent registry if it exists + _ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot)) + + // Determine which agent name to use + agentName := "" + if rigSettings != nil && rigSettings.Agent != "" { + agentName = rigSettings.Agent + } else if townSettings.DefaultAgent != "" { + agentName = townSettings.DefaultAgent + } else { + agentName = "claude" // ultimate fallback + } + + // Look up the agent configuration + return lookupAgentConfig(agentName, townSettings) +} + +// lookupAgentConfig looks up an agent by name. +// First checks town's custom agents, then built-in presets from agents.go. +func lookupAgentConfig(name string, townSettings *TownSettings) *RuntimeConfig { + // First check town's custom agents + if townSettings != nil && townSettings.Agents != nil { + if custom, ok := townSettings.Agents[name]; ok && custom != nil { + return fillRuntimeDefaults(custom) + } + } + + // Check built-in presets from agents.go + if preset := GetAgentPresetByName(name); preset != nil { + return RuntimeConfigFromPreset(AgentPreset(name)) + } + + // Fallback to claude defaults + return DefaultRuntimeConfig() +} + +// fillRuntimeDefaults fills in default values for empty RuntimeConfig fields. +func fillRuntimeDefaults(rc *RuntimeConfig) *RuntimeConfig { + if rc == nil { + return DefaultRuntimeConfig() + } + // Create a copy to avoid modifying the original + result := &RuntimeConfig{ + Command: rc.Command, + Args: rc.Args, + InitialPrompt: rc.InitialPrompt, + } + if result.Command == "" { + result.Command = "claude" + } + if result.Args == nil { + result.Args = []string{"--dangerously-skip-permissions"} + } + return result +} + // GetRuntimeCommand is a convenience function that returns the full command string -// for starting an LLM session. It loads the config and builds the command. +// for starting an LLM session. It resolves the agent config and builds the command. func GetRuntimeCommand(rigPath string) string { - return LoadRuntimeConfig(rigPath).BuildCommand() + if rigPath == "" { + return DefaultRuntimeConfig().BuildCommand() + } + // Derive town root from rig path (rig is typically ~/gt/) + townRoot := filepath.Dir(rigPath) + return ResolveAgentConfig(townRoot, rigPath).BuildCommand() } // GetRuntimeCommandWithPrompt returns the full command with an initial prompt. func GetRuntimeCommandWithPrompt(rigPath, prompt string) string { - return LoadRuntimeConfig(rigPath).BuildCommandWithPrompt(prompt) + if rigPath == "" { + return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt) + } + townRoot := filepath.Dir(rigPath) + return ResolveAgentConfig(townRoot, rigPath).BuildCommandWithPrompt(prompt) } // BuildStartupCommand builds a full startup command with environment exports. @@ -680,7 +806,9 @@ func GetRuntimeCommandWithPrompt(rigPath, prompt string) string { func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string { var rc *RuntimeConfig if rigPath != "" { - rc = LoadRuntimeConfig(rigPath) + // Derive town root from rig path + townRoot := filepath.Dir(rigPath) + rc = ResolveAgentConfig(townRoot, rigPath) } else { rc = DefaultRuntimeConfig() } diff --git a/internal/config/types.go b/internal/config/types.go index 28751f38..27e46e8f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -28,6 +28,38 @@ type MayorConfig struct { DefaultCrewName string `json:"default_crew_name,omitempty"` // default crew name for new rigs } +// CurrentTownSettingsVersion is the current schema version for TownSettings. +const CurrentTownSettingsVersion = 1 + +// TownSettings represents town-level behavioral configuration (settings/config.json). +// This contains agent configuration that applies to all rigs unless overridden. +type TownSettings struct { + Type string `json:"type"` // "town-settings" + 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") + // or a custom agent name defined in settings/agents.json. + // Default: "claude" + DefaultAgent string `json:"default_agent,omitempty"` + + // Agents defines custom agent configurations or overrides. + // Keys are agent names that can be referenced by DefaultAgent or rig settings. + // Values override or extend the built-in presets. + // Example: {"gemini": {"command": "/custom/path/to/gemini"}} + Agents map[string]*RuntimeConfig `json:"agents,omitempty"` +} + +// NewTownSettings creates a new TownSettings with defaults. +func NewTownSettings() *TownSettings { + return &TownSettings{ + Type: "town-settings", + Version: CurrentTownSettingsVersion, + DefaultAgent: "claude", + Agents: make(map[string]*RuntimeConfig), + } +} + // DaemonConfig represents daemon process settings. type DaemonConfig struct { HeartbeatInterval string `json:"heartbeat_interval,omitempty"` // e.g., "30s" @@ -98,7 +130,14 @@ type RigSettings struct { Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings Namepool *NamepoolConfig `json:"namepool,omitempty"` // polecat name pool settings Crew *CrewConfig `json:"crew,omitempty"` // crew startup settings - Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings + 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") + // 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. + Agent string `json:"agent,omitempty"` } // CrewConfig represents crew workspace settings for a rig.