diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go index 51538648..f4b8b58f 100644 --- a/internal/cmd/crew.go +++ b/internal/cmd/crew.go @@ -8,17 +8,18 @@ import ( // Crew command flags var ( - crewRig string - crewBranch bool - crewJSON bool - crewForce bool - crewPurge bool - crewNoTmux bool - crewDetached bool - crewMessage string - crewAccount string - crewAll bool - crewDryRun bool + crewRig string + crewBranch bool + crewJSON bool + crewForce bool + crewPurge bool + crewNoTmux bool + crewDetached bool + crewMessage string + crewAccount string + crewAgentOverride string + crewAll bool + crewDryRun bool ) var crewCmd = &cobra.Command{ @@ -328,6 +329,7 @@ func init() { crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path") crewAtCmd.Flags().BoolVarP(&crewDetached, "detached", "d", false, "Start session without attaching") crewAtCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use (overrides default)") + crewAtCmd.Flags().StringVar(&crewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)") crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)") @@ -350,6 +352,7 @@ func init() { crewStartCmd.Flags().BoolVar(&crewAll, "all", false, "Start all crew members in the rig") crewStartCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use") + crewStartCmd.Flags().StringVar(&crewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)") crewStopCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use (filter when using --all)") crewStopCmd.Flags().BoolVar(&crewAll, "all", false, "Stop all running crew sessions") diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index 51947ff4..bea11b2f 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -150,8 +150,11 @@ func runCrewAt(cmd *cobra.Command, args []string) error { // This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell) // Pass "gt prime" as initial prompt so Claude loads context immediately // Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes - claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, "gt prime") - if err := t.RespawnPane(paneID, claudeCmd); err != nil { + startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + if err := t.RespawnPane(paneID, startupCmd); err != nil { return fmt.Errorf("starting claude: %w", err) } @@ -174,8 +177,11 @@ func runCrewAt(cmd *cobra.Command, args []string) error { // Use respawn-pane to replace shell with Claude directly // Pass "gt prime" as initial prompt so Claude loads context immediately // Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes - claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, "gt prime") - if err := t.RespawnPane(paneID, claudeCmd); err != nil { + startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + if err := t.RespawnPane(paneID, startupCmd); err != nil { return fmt.Errorf("restarting claude: %w", err) } } @@ -185,7 +191,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error { if isInTmuxSession(sessionID) { // 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) + agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride) + if err != nil { + return fmt.Errorf("resolving agent: %w", err) + } fmt.Printf("Starting %s in current session...\n", agentCfg.Command) return execAgent(agentCfg, "gt prime") } diff --git a/internal/cmd/crew_lifecycle.go b/internal/cmd/crew_lifecycle.go index a9c3a3e1..1603b957 100644 --- a/internal/cmd/crew_lifecycle.go +++ b/internal/cmd/crew_lifecycle.go @@ -338,6 +338,7 @@ func runCrewStart(cmd *cobra.Command, args []string) error { // Set the start.go flags before calling runStartCrew startCrewRig = rigName startCrewAccount = crewAccount + startCrewAgentOverride = crewAgentOverride // Use rig/name format for runStartCrew fullName := rigName + "/" + name diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index de04fdf9..9910d2ea 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -89,6 +89,8 @@ Stops the current session (if running) and starts a fresh one.`, RunE: runDeaconRestart, } +var deaconAgentOverride string + var deaconHeartbeatCmd = &cobra.Command{ Use: "heartbeat [action]", Short: "Update the Deacon heartbeat", @@ -203,7 +205,6 @@ Examples: RunE: runDeaconStaleHooks, } - var ( triggerTimeout time.Duration @@ -258,6 +259,10 @@ func init() { deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false, "Preview what would be unhooked without making changes") + deaconStartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)") + deaconAttachCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)") + deaconRestartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)") + rootCmd.AddCommand(deaconCmd) } @@ -275,7 +280,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("Deacon session already running. Attach with: gt deacon attach") } - if err := startDeaconSession(t, sessionName); err != nil { + if err := startDeaconSession(t, sessionName, deaconAgentOverride); err != nil { return err } @@ -287,7 +292,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error { } // startDeaconSession creates and initializes the Deacon tmux session. -func startDeaconSession(t *tmux.Tmux, sessionName string) error { +func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error { // Find workspace root townRoot, err := workspace.FindFromCwdOrError() if err != nil { @@ -326,7 +331,11 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error { // Restarts are handled by daemon via ensureDeaconRunning on each heartbeat // The startup hook handles context loading automatically // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes - if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("deacon", "deacon", "", "")); err != nil { + startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "deacon", "", "", agentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + if err := t.SendKeys(sessionName, startupCmd); err != nil { return fmt.Errorf("sending command: %w", err) } @@ -394,7 +403,7 @@ func runDeaconAttach(cmd *cobra.Command, args []string) error { if !running { // Auto-start if not running fmt.Println("Deacon session not running, starting...") - if err := startDeaconSession(t, sessionName); err != nil { + if err := startDeaconSession(t, sessionName, deaconAgentOverride); err != nil { return err } } @@ -942,4 +951,3 @@ func runDeaconStaleHooks(cmd *cobra.Command, args []string) error { return nil } - diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 11efdc1b..84c964bd 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -31,6 +31,8 @@ The Mayor is the global coordinator for Gas Town, running as a persistent tmux session. Use the subcommands to start, stop, attach, and check status.`, } +var mayorAgentOverride string + var mayorStartCmd = &cobra.Command{ Use: "start", Short: "Start the Mayor session", @@ -84,6 +86,10 @@ func init() { mayorCmd.AddCommand(mayorStatusCmd) mayorCmd.AddCommand(mayorRestartCmd) + mayorStartCmd.Flags().StringVar(&mayorAgentOverride, "agent", "", "Agent alias to run the Mayor with (overrides town default)") + mayorAttachCmd.Flags().StringVar(&mayorAgentOverride, "agent", "", "Agent alias to run the Mayor with (overrides town default)") + mayorRestartCmd.Flags().StringVar(&mayorAgentOverride, "agent", "", "Agent alias to run the Mayor with (overrides town default)") + rootCmd.AddCommand(mayorCmd) } @@ -101,7 +107,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach") } - if err := startMayorSession(t, sessionName); err != nil { + if err := startMayorSession(t, sessionName, mayorAgentOverride); err != nil { return err } @@ -113,7 +119,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error { } // startMayorSession creates and initializes the Mayor tmux session. -func startMayorSession(t *tmux.Tmux, sessionName string) error { +func startMayorSession(t *tmux.Tmux, sessionName, agentOverride string) error { // Find workspace root townRoot, err := workspace.FindFromCwdOrError() if err != nil { @@ -139,8 +145,11 @@ func startMayorSession(t *tmux.Tmux, sessionName string) error { // Use SendKeysDelayed to allow shell initialization after NewSession // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes // Mayor uses default runtime config (empty rigPath) since it's not rig-specific - claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "") - if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { + startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", agentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + if err := t.SendKeysDelayed(sessionName, startupCmd, 200); err != nil { return fmt.Errorf("sending command: %w", err) } @@ -208,7 +217,7 @@ func runMayorAttach(cmd *cobra.Command, args []string) error { if !running { // Auto-start if not running fmt.Println("Mayor session not running, starting...") - if err := startMayorSession(t, sessionName); err != nil { + if err := startMayorSession(t, sessionName, mayorAgentOverride); err != nil { return err } } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 00187286..32124921 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -24,16 +24,18 @@ import ( ) var ( - startAll bool - startCrewRig string - startCrewAccount string - shutdownGraceful bool - shutdownWait int - shutdownAll bool - shutdownForce bool - shutdownYes bool - shutdownPolecatsOnly bool - shutdownNuclear bool + startAll bool + startAgentOverride string + startCrewRig string + startCrewAccount string + startCrewAgentOverride string + shutdownGraceful bool + shutdownWait int + shutdownAll bool + shutdownForce bool + shutdownYes bool + shutdownPolecatsOnly bool + shutdownNuclear bool ) var startCmd = &cobra.Command{ @@ -104,9 +106,11 @@ Examples: func init() { startCmd.Flags().BoolVarP(&startAll, "all", "a", false, "Also start Witnesses and Refineries for all rigs") + startCmd.Flags().StringVar(&startAgentOverride, "agent", "", "Agent alias to run Mayor/Deacon with (overrides town default)") startCrewCmd.Flags().StringVar(&startCrewRig, "rig", "", "Rig to use") startCrewCmd.Flags().StringVar(&startCrewAccount, "account", "", "Claude Code account handle to use") + startCrewCmd.Flags().StringVar(&startCrewAgentOverride, "agent", "", "Agent alias to run crew worker with (overrides rig/town default)") startCmd.AddCommand(startCrewCmd) shutdownCmd.Flags().BoolVarP(&shutdownGraceful, "graceful", "g", false, @@ -155,7 +159,7 @@ func runStart(cmd *cobra.Command, args []string) error { fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot)) // Start core agents (Mayor and Deacon) - if err := startCoreAgents(t); err != nil { + if err := startCoreAgents(t, startAgentOverride); err != nil { return err } @@ -182,7 +186,7 @@ func runStart(cmd *cobra.Command, args []string) error { } // startCoreAgents starts Mayor and Deacon sessions. -func startCoreAgents(t *tmux.Tmux) error { +func startCoreAgents(t *tmux.Tmux, agentOverride string) error { // Get session names mayorSession := getMayorSessionName() deaconSession := getDeaconSessionName() @@ -193,7 +197,7 @@ func startCoreAgents(t *tmux.Tmux) error { fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○")) } else { fmt.Printf(" %s Starting Mayor...\n", style.Bold.Render("→")) - if err := startMayorSession(t, mayorSession); err != nil { + if err := startMayorSession(t, mayorSession, agentOverride); err != nil { return fmt.Errorf("starting Mayor: %w", err) } fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓")) @@ -205,7 +209,7 @@ func startCoreAgents(t *tmux.Tmux) error { fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○")) } else { fmt.Printf(" %s Starting Deacon...\n", style.Bold.Render("→")) - if err := startDeaconSession(t, deaconSession); err != nil { + if err := startDeaconSession(t, deaconSession, agentOverride); err != nil { return fmt.Errorf("starting Deacon: %w", err) } fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓")) @@ -799,8 +803,11 @@ func runStartCrew(cmd *cobra.Command, args []string) error { if !t.IsClaudeRunning(sessionID) { // Claude has exited, restart it with "gt prime" as initial prompt fmt.Printf("Session exists, restarting Claude...\n") - claudeCmd := config.BuildCrewStartupCommand(rigName, name, r.Path, "gt prime") - if err := t.SendKeys(sessionID, claudeCmd); err != nil { + startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(rigName, name, r.Path, "gt prime", startCrewAgentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + if err := t.SendKeys(sessionID, startupCmd); err != nil { return fmt.Errorf("restarting claude: %w", err) } } else { @@ -833,8 +840,11 @@ func runStartCrew(cmd *cobra.Command, args []string) error { // Start claude with skip permissions and proper env vars for seance // Pass "gt prime" as initial prompt so context is loaded immediately - claudeCmd := config.BuildCrewStartupCommand(rigName, name, r.Path, "gt prime") - if err := t.SendKeys(sessionID, claudeCmd); err != nil { + startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(rigName, name, r.Path, "gt prime", startCrewAgentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + if err := t.SendKeys(sessionID, startupCmd); err != nil { return fmt.Errorf("starting claude: %w", err) } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 05adb8cb..83752f60 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -1035,6 +1035,89 @@ func TestBuildPolecatStartupCommandWithAgentOverride(t *testing.T) { } } +func TestBuildAgentStartupCommandWithAgentOverride(t *testing.T) { + townRoot := t.TempDir() + + if err := os.MkdirAll(filepath.Join(townRoot, "mayor"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(townRoot, "mayor", "town.json"), []byte("{}"), 0600); err != nil { + t.Fatalf("WriteFile town.json: %v", err) + } + + townSettings := NewTownSettings() + townSettings.DefaultAgent = "gemini" + if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil { + t.Fatalf("SaveTownSettings: %v", err) + } + + originalWd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(originalWd) }) + if err := os.Chdir(townRoot); err != nil { + t.Fatalf("Chdir: %v", err) + } + + t.Run("empty override uses default agent", func(t *testing.T) { + cmd, err := BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", "") + if err != nil { + t.Fatalf("BuildAgentStartupCommandWithAgentOverride: %v", err) + } + if !strings.Contains(cmd, "GT_ROLE=mayor") { + t.Fatalf("expected GT_ROLE export in command: %q", cmd) + } + if !strings.Contains(cmd, "BD_ACTOR=mayor") { + t.Fatalf("expected BD_ACTOR export in command: %q", cmd) + } + if !strings.Contains(cmd, "gemini --approval-mode yolo") { + t.Fatalf("expected gemini command in output: %q", cmd) + } + }) + + t.Run("override switches agent", func(t *testing.T) { + cmd, err := BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", "codex") + if err != nil { + t.Fatalf("BuildAgentStartupCommandWithAgentOverride: %v", err) + } + if !strings.Contains(cmd, "codex") { + t.Fatalf("expected codex command in output: %q", cmd) + } + }) +} + +func TestBuildCrewStartupCommandWithAgentOverride(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) + } + + if err := SaveRigSettings(RigSettingsPath(rigPath), NewRigSettings()); err != nil { + t.Fatalf("SaveRigSettings: %v", err) + } + + cmd, err := BuildCrewStartupCommandWithAgentOverride("testrig", "max", rigPath, "gt prime", "gemini") + if err != nil { + t.Fatalf("BuildCrewStartupCommandWithAgentOverride: %v", err) + } + if !strings.Contains(cmd, "GT_ROLE=crew") { + 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_CREW=max") { + t.Fatalf("expected GT_CREW export in command: %q", cmd) + } + if !strings.Contains(cmd, "BD_ACTOR=testrig/crew/max") { + t.Fatalf("expected BD_ACTOR 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()