feat: add --agent overrides to start/attach

This commit is contained in:
jv
2026-01-07 12:36:35 +13:00
committed by Steve Yegge
parent 3b9ca71fc4
commit 6afd85df4b
7 changed files with 168 additions and 45 deletions
+3
View File
@@ -17,6 +17,7 @@ var (
crewDetached bool crewDetached bool
crewMessage string crewMessage string
crewAccount string crewAccount string
crewAgentOverride string
crewAll bool crewAll bool
crewDryRun bool crewDryRun bool
) )
@@ -328,6 +329,7 @@ func init() {
crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path") crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path")
crewAtCmd.Flags().BoolVarP(&crewDetached, "detached", "d", false, "Start session without attaching") 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(&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().StringVar(&crewRig, "rig", "", "Rig to use")
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)") 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().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(&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().StringVar(&crewRig, "rig", "", "Rig to use (filter when using --all)")
crewStopCmd.Flags().BoolVar(&crewAll, "all", false, "Stop all running crew sessions") crewStopCmd.Flags().BoolVar(&crewAll, "all", false, "Stop all running crew sessions")
+14 -5
View File
@@ -150,8 +150,11 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
// This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell) // This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell)
// Pass "gt prime" as initial prompt so Claude loads context immediately // Pass "gt prime" as initial prompt so Claude loads context immediately
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes // Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, "gt prime") startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
if err := t.RespawnPane(paneID, claudeCmd); err != nil { 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) 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 // Use respawn-pane to replace shell with Claude directly
// Pass "gt prime" as initial prompt so Claude loads context immediately // Pass "gt prime" as initial prompt so Claude loads context immediately
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes // Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, "gt prime") startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
if err := t.RespawnPane(paneID, claudeCmd); err != nil { 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) return fmt.Errorf("restarting claude: %w", err)
} }
} }
@@ -185,7 +191,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
if isInTmuxSession(sessionID) { if isInTmuxSession(sessionID) {
// We're in the session at a shell prompt - just start the agent directly // 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 // 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) fmt.Printf("Starting %s in current session...\n", agentCfg.Command)
return execAgent(agentCfg, "gt prime") return execAgent(agentCfg, "gt prime")
} }
+1
View File
@@ -338,6 +338,7 @@ func runCrewStart(cmd *cobra.Command, args []string) error {
// Set the start.go flags before calling runStartCrew // Set the start.go flags before calling runStartCrew
startCrewRig = rigName startCrewRig = rigName
startCrewAccount = crewAccount startCrewAccount = crewAccount
startCrewAgentOverride = crewAgentOverride
// Use rig/name format for runStartCrew // Use rig/name format for runStartCrew
fullName := rigName + "/" + name fullName := rigName + "/" + name
+14 -6
View File
@@ -89,6 +89,8 @@ Stops the current session (if running) and starts a fresh one.`,
RunE: runDeaconRestart, RunE: runDeaconRestart,
} }
var deaconAgentOverride string
var deaconHeartbeatCmd = &cobra.Command{ var deaconHeartbeatCmd = &cobra.Command{
Use: "heartbeat [action]", Use: "heartbeat [action]",
Short: "Update the Deacon heartbeat", Short: "Update the Deacon heartbeat",
@@ -203,7 +205,6 @@ Examples:
RunE: runDeaconStaleHooks, RunE: runDeaconStaleHooks,
} }
var ( var (
triggerTimeout time.Duration triggerTimeout time.Duration
@@ -258,6 +259,10 @@ func init() {
deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false, deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false,
"Preview what would be unhooked without making changes") "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) 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") 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 return err
} }
@@ -287,7 +292,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error {
} }
// startDeaconSession creates and initializes the Deacon tmux session. // 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 // Find workspace root
townRoot, err := workspace.FindFromCwdOrError() townRoot, err := workspace.FindFromCwdOrError()
if err != nil { 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 // Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
// The startup hook handles context loading automatically // The startup hook handles context loading automatically
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes // 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) return fmt.Errorf("sending command: %w", err)
} }
@@ -394,7 +403,7 @@ func runDeaconAttach(cmd *cobra.Command, args []string) error {
if !running { if !running {
// Auto-start if not running // Auto-start if not running
fmt.Println("Deacon session not running, starting...") fmt.Println("Deacon session not running, starting...")
if err := startDeaconSession(t, sessionName); err != nil { if err := startDeaconSession(t, sessionName, deaconAgentOverride); err != nil {
return err return err
} }
} }
@@ -942,4 +951,3 @@ func runDeaconStaleHooks(cmd *cobra.Command, args []string) error {
return nil return nil
} }
+14 -5
View File
@@ -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.`, tmux session. Use the subcommands to start, stop, attach, and check status.`,
} }
var mayorAgentOverride string
var mayorStartCmd = &cobra.Command{ var mayorStartCmd = &cobra.Command{
Use: "start", Use: "start",
Short: "Start the Mayor session", Short: "Start the Mayor session",
@@ -84,6 +86,10 @@ func init() {
mayorCmd.AddCommand(mayorStatusCmd) mayorCmd.AddCommand(mayorStatusCmd)
mayorCmd.AddCommand(mayorRestartCmd) 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) 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") 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 return err
} }
@@ -113,7 +119,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
} }
// startMayorSession creates and initializes the Mayor tmux session. // 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 // Find workspace root
townRoot, err := workspace.FindFromCwdOrError() townRoot, err := workspace.FindFromCwdOrError()
if err != nil { if err != nil {
@@ -139,8 +145,11 @@ func startMayorSession(t *tmux.Tmux, sessionName string) error {
// Use SendKeysDelayed to allow shell initialization after NewSession // Use SendKeysDelayed to allow shell initialization after NewSession
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes // 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 // Mayor uses default runtime config (empty rigPath) since it's not rig-specific
claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "") startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", agentOverride)
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { 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) return fmt.Errorf("sending command: %w", err)
} }
@@ -208,7 +217,7 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
if !running { if !running {
// Auto-start if not running // Auto-start if not running
fmt.Println("Mayor session not running, starting...") fmt.Println("Mayor session not running, starting...")
if err := startMayorSession(t, sessionName); err != nil { if err := startMayorSession(t, sessionName, mayorAgentOverride); err != nil {
return err return err
} }
} }
+18 -8
View File
@@ -25,8 +25,10 @@ import (
var ( var (
startAll bool startAll bool
startAgentOverride string
startCrewRig string startCrewRig string
startCrewAccount string startCrewAccount string
startCrewAgentOverride string
shutdownGraceful bool shutdownGraceful bool
shutdownWait int shutdownWait int
shutdownAll bool shutdownAll bool
@@ -104,9 +106,11 @@ Examples:
func init() { func init() {
startCmd.Flags().BoolVarP(&startAll, "all", "a", false, startCmd.Flags().BoolVarP(&startAll, "all", "a", false,
"Also start Witnesses and Refineries for all rigs") "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(&startCrewRig, "rig", "", "Rig to use")
startCrewCmd.Flags().StringVar(&startCrewAccount, "account", "", "Claude Code account handle 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) startCmd.AddCommand(startCrewCmd)
shutdownCmd.Flags().BoolVarP(&shutdownGraceful, "graceful", "g", false, 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)) fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot))
// Start core agents (Mayor and Deacon) // Start core agents (Mayor and Deacon)
if err := startCoreAgents(t); err != nil { if err := startCoreAgents(t, startAgentOverride); err != nil {
return err return err
} }
@@ -182,7 +186,7 @@ func runStart(cmd *cobra.Command, args []string) error {
} }
// startCoreAgents starts Mayor and Deacon sessions. // startCoreAgents starts Mayor and Deacon sessions.
func startCoreAgents(t *tmux.Tmux) error { func startCoreAgents(t *tmux.Tmux, agentOverride string) error {
// Get session names // Get session names
mayorSession := getMayorSessionName() mayorSession := getMayorSessionName()
deaconSession := getDeaconSessionName() deaconSession := getDeaconSessionName()
@@ -193,7 +197,7 @@ func startCoreAgents(t *tmux.Tmux) error {
fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○")) fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○"))
} else { } else {
fmt.Printf(" %s Starting Mayor...\n", style.Bold.Render("→")) 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) return fmt.Errorf("starting Mayor: %w", err)
} }
fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓")) 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("○")) fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
} else { } else {
fmt.Printf(" %s Starting Deacon...\n", style.Bold.Render("→")) 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) return fmt.Errorf("starting Deacon: %w", err)
} }
fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓")) 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) { if !t.IsClaudeRunning(sessionID) {
// Claude has exited, restart it with "gt prime" as initial prompt // Claude has exited, restart it with "gt prime" as initial prompt
fmt.Printf("Session exists, restarting Claude...\n") fmt.Printf("Session exists, restarting Claude...\n")
claudeCmd := config.BuildCrewStartupCommand(rigName, name, r.Path, "gt prime") startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(rigName, name, r.Path, "gt prime", startCrewAgentOverride)
if err := t.SendKeys(sessionID, claudeCmd); err != nil { 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) return fmt.Errorf("restarting claude: %w", err)
} }
} else { } else {
@@ -833,8 +840,11 @@ func runStartCrew(cmd *cobra.Command, args []string) error {
// Start claude with skip permissions and proper env vars for seance // Start claude with skip permissions and proper env vars for seance
// Pass "gt prime" as initial prompt so context is loaded immediately // Pass "gt prime" as initial prompt so context is loaded immediately
claudeCmd := config.BuildCrewStartupCommand(rigName, name, r.Path, "gt prime") startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(rigName, name, r.Path, "gt prime", startCrewAgentOverride)
if err := t.SendKeys(sessionID, claudeCmd); err != nil { 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) return fmt.Errorf("starting claude: %w", err)
} }
+83
View File
@@ -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) { func TestLoadRuntimeConfigFromSettings(t *testing.T) {
// Create temp rig with custom runtime config // Create temp rig with custom runtime config
dir := t.TempDir() dir := t.TempDir()