diff --git a/internal/cmd/polecat_spawn.go b/internal/cmd/polecat_spawn.go index 879e34a4..1aa9b9c7 100644 --- a/internal/cmd/polecat_spawn.go +++ b/internal/cmd/polecat_spawn.go @@ -39,6 +39,7 @@ type SlingSpawnOptions struct { Account string // Claude Code account handle to use Create bool // Create polecat if it doesn't exist (currently always true for sling) HookBead string // Bead ID to set as hook_bead at spawn time (atomic assignment) + Agent string // Agent override for this spawn (e.g., "gemini", "codex", "claude-haiku") } // SpawnPolecatForSling creates a fresh polecat and optionally starts its session. @@ -122,8 +123,11 @@ 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) - // Use rig's configured agent command - agentCmd := config.ResolveAgentConfig(townRoot, r.Path).BuildCommand() + // Use rig's configured agent command, unless overridden. + agentCmd, err := config.GetRuntimeCommandWithAgentOverride(r.Path, opts.Agent) + if err != nil { + return nil, err + } fmt.Printf(" %s\n\n", agentCmd) fmt.Printf("Agent will discover work via gt prime on startup.\n") @@ -157,6 +161,13 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec startOpts := session.StartOptions{ ClaudeConfigDir: claudeConfigDir, } + if opts.Agent != "" { + cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, r.Path, "", opts.Agent) + if err != nil { + return nil, err + } + startOpts.Command = cmd + } if err := sessMgr.Start(polecatName, startOpts); err != nil { return nil, fmt.Errorf("starting session: %w", err) } diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 2d211613..07575a9f 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -104,6 +104,7 @@ var ( slingCreate bool // --create: create polecat if it doesn't exist slingForce bool // --force: force spawn even if polecat has unread mail slingAccount string // --account: Claude Code account handle to use + slingAgent string // --agent: override runtime agent for this sling/spawn slingNoConvoy bool // --no-convoy: skip auto-convoy creation ) @@ -120,6 +121,7 @@ func init() { slingCmd.Flags().BoolVar(&slingCreate, "create", false, "Create polecat if it doesn't exist") slingCmd.Flags().BoolVar(&slingForce, "force", false, "Force spawn even if polecat has unread mail") slingCmd.Flags().StringVar(&slingAccount, "account", "", "Claude Code account handle to use") + slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)") slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling") rootCmd.AddCommand(slingCmd) @@ -243,6 +245,7 @@ func runSling(cmd *cobra.Command, args []string) error { Account: slingAccount, Create: slingCreate, HookBead: beadID, // Set atomically at spawn time + Agent: slingAgent, } spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts) if spawnErr != nil { @@ -849,6 +852,7 @@ func runSlingFormula(args []string) error { Naked: slingNaked, Account: slingAccount, Create: slingCreate, + Agent: slingAgent, } spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts) if spawnErr != nil { @@ -1101,7 +1105,6 @@ func agentIDToBeadID(agentID, townRoot string) string { } } - // IsDogTarget checks if target is a dog target pattern. // Returns the dog name (or empty for pool dispatch) and true if it's a dog target. // Patterns: @@ -1395,6 +1398,7 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error Account: slingAccount, Create: slingCreate, HookBead: beadID, // Set atomically at spawn time + Agent: slingAgent, } spawnInfo, err := SpawnPolecatForSling(rigName, spawnOpts) if err != nil { diff --git a/internal/config/loader.go b/internal/config/loader.go index 5b38f4cb..62b8de68 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -838,6 +838,61 @@ func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig { return lookupAgentConfig(agentName, townSettings) } +// ResolveAgentConfigWithOverride resolves the agent configuration for a rig, with an optional override. +// If agentOverride is non-empty, it is used instead of rig/town defaults. +// Returns the resolved RuntimeConfig, the selected agent name, and an error if the override name +// does not exist in town custom agents or built-in presets. +func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*RuntimeConfig, string, error) { + // Load rig settings + rigSettings, err := LoadRigSettings(RigSettingsPath(rigPath)) + if err != nil { + rigSettings = nil + } + + // Backwards compatibility: if Runtime is set directly, use it (but still report agentOverride if present) + if rigSettings != nil && rigSettings.Runtime != nil && agentOverride == "" { + rc := rigSettings.Runtime + return fillRuntimeDefaults(rc), "", nil + } + + // 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 agentOverride != "" { + agentName = agentOverride + } else if rigSettings != nil && rigSettings.Agent != "" { + agentName = rigSettings.Agent + } else if townSettings.DefaultAgent != "" { + agentName = townSettings.DefaultAgent + } else { + agentName = "claude" // ultimate fallback + } + + // If an override is requested, validate it exists. + if agentOverride != "" { + if townSettings.Agents != nil { + if custom, ok := townSettings.Agents[agentName]; ok && custom != nil { + return fillRuntimeDefaults(custom), agentName, nil + } + } + if preset := GetAgentPresetByName(agentName); preset != nil { + return RuntimeConfigFromPreset(AgentPreset(agentName)), agentName, nil + } + return nil, "", fmt.Errorf("agent '%s' not found", agentName) + } + + // Normal lookup path (no override) + return lookupAgentConfig(agentName, townSettings), agentName, nil +} + // 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 { @@ -893,6 +948,29 @@ func GetRuntimeCommand(rigPath string) string { return ResolveAgentConfig(townRoot, rigPath).BuildCommand() } +// GetRuntimeCommandWithAgentOverride returns the full command for starting an LLM session, +// using agentOverride if non-empty. +func GetRuntimeCommandWithAgentOverride(rigPath, agentOverride string) (string, error) { + if rigPath == "" { + townRoot, err := findTownRootFromCwd() + if err != nil { + return DefaultRuntimeConfig().BuildCommand(), nil + } + rc, _, resolveErr := ResolveAgentConfigWithOverride(townRoot, "", agentOverride) + if resolveErr != nil { + return "", resolveErr + } + return rc.BuildCommand(), nil + } + + townRoot := filepath.Dir(rigPath) + rc, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride) + if err != nil { + return "", err + } + return rc.BuildCommand(), nil +} + // GetRuntimeCommandWithPrompt returns the full command with an initial prompt. func GetRuntimeCommandWithPrompt(rigPath, prompt string) string { if rigPath == "" { @@ -907,6 +985,29 @@ func GetRuntimeCommandWithPrompt(rigPath, prompt string) string { return ResolveAgentConfig(townRoot, rigPath).BuildCommandWithPrompt(prompt) } +// GetRuntimeCommandWithPromptAndAgentOverride returns the full command with an initial prompt, +// using agentOverride if non-empty. +func GetRuntimeCommandWithPromptAndAgentOverride(rigPath, prompt, agentOverride string) (string, error) { + if rigPath == "" { + townRoot, err := findTownRootFromCwd() + if err != nil { + return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt), nil + } + rc, _, resolveErr := ResolveAgentConfigWithOverride(townRoot, "", agentOverride) + if resolveErr != nil { + return "", resolveErr + } + return rc.BuildCommandWithPrompt(prompt), nil + } + + townRoot := filepath.Dir(rigPath) + rc, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride) + if err != nil { + return "", err + } + return rc.BuildCommandWithPrompt(prompt), nil +} + // findTownRootFromCwd locates the town root by walking up from cwd. // It looks for the mayor/town.json marker file. // Returns empty string and no error if not found (caller should use defaults). @@ -981,6 +1082,52 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri return cmd } +// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand, +// but uses agentOverride if non-empty. +func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) { + var rc *RuntimeConfig + + if rigPath != "" { + townRoot := filepath.Dir(rigPath) + var err error + rc, _, err = ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride) + if err != nil { + return "", err + } + } else { + townRoot, err := findTownRootFromCwd() + if err != nil { + rc = DefaultRuntimeConfig() + } else { + var resolveErr error + rc, _, resolveErr = ResolveAgentConfigWithOverride(townRoot, "", agentOverride) + if resolveErr != nil { + return "", resolveErr + } + } + } + + // Build environment export prefix + var exports []string + for k, v := range envVars { + exports = append(exports, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(exports) + + var cmd string + if len(exports) > 0 { + cmd = "export " + strings.Join(exports, " ") + " && " + } + + if prompt != "" { + cmd += rc.BuildCommandWithPrompt(prompt) + } else { + cmd += rc.BuildCommand() + } + + return cmd, nil +} + // BuildAgentStartupCommand is a convenience function for starting agent sessions. // It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME) // and builds the full startup command. @@ -993,6 +1140,16 @@ func BuildAgentStartupCommand(role, bdActor, rigPath, prompt string) string { return BuildStartupCommand(envVars, rigPath, prompt) } +// BuildAgentStartupCommandWithAgentOverride is like BuildAgentStartupCommand, but uses agentOverride if non-empty. +func BuildAgentStartupCommandWithAgentOverride(role, bdActor, rigPath, prompt, agentOverride string) (string, error) { + envVars := map[string]string{ + "GT_ROLE": role, + "BD_ACTOR": bdActor, + "GIT_AUTHOR_NAME": bdActor, + } + return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride) +} + // BuildPolecatStartupCommand builds the startup command for a polecat. // Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, and GIT_AUTHOR_NAME. func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string { @@ -1007,6 +1164,19 @@ func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) st return BuildStartupCommand(envVars, rigPath, prompt) } +// BuildPolecatStartupCommandWithAgentOverride is like BuildPolecatStartupCommand, but uses agentOverride if non-empty. +func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride string) (string, error) { + bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) + envVars := map[string]string{ + "GT_ROLE": "polecat", + "GT_RIG": rigName, + "GT_POLECAT": polecatName, + "BD_ACTOR": bdActor, + "GIT_AUTHOR_NAME": polecatName, + } + return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride) +} + // BuildCrewStartupCommand builds the startup command for a crew member. // Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, and GIT_AUTHOR_NAME. func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string { @@ -1021,6 +1191,19 @@ func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string { return BuildStartupCommand(envVars, rigPath, prompt) } +// BuildCrewStartupCommandWithAgentOverride is like BuildCrewStartupCommand, but uses agentOverride if non-empty. +func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride string) (string, error) { + bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName) + envVars := map[string]string{ + "GT_ROLE": "crew", + "GT_RIG": rigName, + "GT_CREW": crewName, + "BD_ACTOR": bdActor, + "GIT_AUTHOR_NAME": crewName, + } + return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride) +} + // GetRigPrefix returns the beads prefix for a rig from rigs.json. // Falls back to "gt" if the rig isn't found or has no prefix configured. // townRoot is the path to the town directory (e.g., ~/gt). diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 43ebba23..05adb8cb 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -931,6 +931,110 @@ func TestBuildCrewStartupCommand(t *testing.T) { } } +func TestResolveAgentConfigWithOverride(t *testing.T) { + townRoot := t.TempDir() + rigPath := filepath.Join(townRoot, "testrig") + + // Town settings: default agent is gemini, plus a custom alias. + townSettings := NewTownSettings() + townSettings.DefaultAgent = "gemini" + townSettings.Agents["claude-haiku"] = &RuntimeConfig{ + Command: "claude", + Args: []string{"--model", "haiku", "--dangerously-skip-permissions"}, + } + if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil { + t.Fatalf("SaveTownSettings: %v", err) + } + + // Rig settings: prefer codex unless overridden. + rigSettings := NewRigSettings() + rigSettings.Agent = "codex" + if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil { + t.Fatalf("SaveRigSettings: %v", err) + } + + t.Run("no override uses rig agent", func(t *testing.T) { + rc, name, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "") + if err != nil { + t.Fatalf("ResolveAgentConfigWithOverride: %v", err) + } + if name != "codex" { + t.Fatalf("name = %q, want %q", name, "codex") + } + if rc.Command != "codex" { + t.Fatalf("rc.Command = %q, want %q", rc.Command, "codex") + } + }) + + t.Run("override uses built-in preset", func(t *testing.T) { + rc, name, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "gemini") + if err != nil { + t.Fatalf("ResolveAgentConfigWithOverride: %v", err) + } + if name != "gemini" { + t.Fatalf("name = %q, want %q", name, "gemini") + } + if rc.Command != "gemini" { + t.Fatalf("rc.Command = %q, want %q", rc.Command, "gemini") + } + }) + + t.Run("override uses custom agent alias", func(t *testing.T) { + rc, name, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "claude-haiku") + if err != nil { + t.Fatalf("ResolveAgentConfigWithOverride: %v", err) + } + if name != "claude-haiku" { + t.Fatalf("name = %q, want %q", name, "claude-haiku") + } + if rc.Command != "claude" { + t.Fatalf("rc.Command = %q, want %q", rc.Command, "claude") + } + if got := rc.BuildCommand(); got != "claude --model haiku --dangerously-skip-permissions" { + t.Fatalf("BuildCommand() = %q, want %q", got, "claude --model haiku --dangerously-skip-permissions") + } + }) + + t.Run("unknown override errors", func(t *testing.T) { + _, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "nope-not-an-agent") + if err == nil { + t.Fatal("expected error for unknown agent override") + } + }) +} + +func TestBuildPolecatStartupCommandWithAgentOverride(t *testing.T) { + townRoot := t.TempDir() + rigPath := filepath.Join(townRoot, "testrig") + + townSettings := NewTownSettings() + if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil { + t.Fatalf("SaveTownSettings: %v", err) + } + + // The rig settings file must exist for resolver calls that load it. + if err := SaveRigSettings(RigSettingsPath(rigPath), NewRigSettings()); err != nil { + t.Fatalf("SaveRigSettings: %v", err) + } + + cmd, err := BuildPolecatStartupCommandWithAgentOverride("testrig", "toast", rigPath, "", "gemini") + if err != nil { + t.Fatalf("BuildPolecatStartupCommandWithAgentOverride: %v", err) + } + if !strings.Contains(cmd, "GT_ROLE=polecat") { + t.Fatalf("expected GT_ROLE export in command: %q", cmd) + } + if !strings.Contains(cmd, "GT_RIG=testrig") { + t.Fatalf("expected GT_RIG export in command: %q", cmd) + } + if !strings.Contains(cmd, "GT_POLECAT=toast") { + t.Fatalf("expected GT_POLECAT export in command: %q", cmd) + } + if !strings.Contains(cmd, "gemini --approval-mode yolo") { + t.Fatalf("expected gemini command in output: %q", cmd) + } +} + func TestLoadRuntimeConfigFromSettings(t *testing.T) { // Create temp rig with custom runtime config dir := t.TempDir()