Unify agent startup with Manager pattern

Refactors all agent startup paths (witness, refinery, crew, polecat) to use
a consistent Manager interface with Start(), Stop(), IsRunning(), and
SessionName() methods.

Includes:
- Witness manager with GUPP propulsion nudge for startup
- Refinery manager for engineer sessions
- Crew manager for worker agents
- Session/polecat manager updates
- claude_settings_check doctor check for settings validation
- Settings management consolidated from rig/manager.go
- Settings location moved outside source repos to prevent conflicts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-06 15:16:33 -08:00
parent 81a7d04239
commit 72544cc06d
12 changed files with 935 additions and 384 deletions

View File

@@ -2,6 +2,7 @@ package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
@@ -344,8 +345,10 @@ func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) {
refineryRigDir = r.Path
}
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
if err := claude.EnsureSettingsForRole(refineryRigDir, "refinery"); err != nil {
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
// write into the source repo. Claude walks up the tree to find settings.
refineryParentDir := filepath.Join(r.Path, "refinery")
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
return false, fmt.Errorf("ensuring Claude settings: %w", err)
}
@@ -762,25 +765,6 @@ func runStartCrew(cmd *cobra.Command, args []string) error {
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
// Check if crew exists, create if not
worker, err := crewMgr.Get(name)
if err == crew.ErrCrewNotFound {
fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName)
worker, err = crewMgr.Add(name, false) // No feature branch for crew
if err != nil {
return fmt.Errorf("creating crew workspace: %w", err)
}
fmt.Printf("%s Created crew workspace: %s/%s\n",
style.Bold.Render("✓"), rigName, name)
} else if err != nil {
return fmt.Errorf("getting crew worker: %w", err)
} else {
fmt.Printf("Crew workspace %s/%s exists\n", rigName, name)
}
// Ensure crew workspace is on default branch
ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, name), r.Path)
// Resolve account for Claude config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, startCrewAccount)
@@ -791,68 +775,18 @@ func runStartCrew(cmd *cobra.Command, args []string) error {
fmt.Printf("Using account: %s\n", accountHandle)
}
// Check if session exists
t := tmux.NewTmux()
sessionID := crewSessionName(rigName, name)
hasSession, err := t.HasSession(sessionID)
// Use manager's Start() method - handles workspace creation, settings, and session
err = crewMgr.Start(name, crew.StartOptions{
Account: startCrewAccount,
ClaudeConfigDir: claudeConfigDir,
})
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if hasSession {
// Session exists - check if Claude is still running
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, startCrewAgentOverride)
if err != nil {
return fmt.Errorf("resolving agent: %w", err)
}
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
// Claude has exited, restart it with "gt prime" as initial prompt
fmt.Printf("Session exists, restarting Claude...\n")
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)
}
if errors.Is(err, crew.ErrSessionRunning) {
fmt.Printf("%s Session already running: %s\n", style.Dim.Render("○"), crewMgr.SessionName(name))
} else {
fmt.Printf("%s Session already running: %s\n", style.Dim.Render("○"), sessionID)
return err
}
} else {
// Create new session
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
if claudeConfigDir != "" {
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
}
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
// Note: ConfigureGasTownSession includes cycle bindings
theme := getThemeForRig(rigName)
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, name, "crew")
// Wait for shell to be ready after session creation
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude with skip permissions and proper env vars for seance
// Pass "gt prime" as initial prompt so context is loaded immediately
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)
}
fmt.Printf("%s Started crew workspace: %s/%s\n",
style.Bold.Render("✓"), rigName, name)
}
@@ -926,54 +860,14 @@ func startCrewMember(rigName, crewName, townRoot string) error {
return fmt.Errorf("rig '%s' not found", rigName)
}
// Create crew manager
// Create crew manager and use Start() method
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
// Check if crew exists, create if not
worker, err := crewMgr.Get(crewName)
if err == crew.ErrCrewNotFound {
worker, err = crewMgr.Add(crewName, false)
if err != nil {
return fmt.Errorf("creating crew workspace: %w", err)
}
} else if err != nil {
return fmt.Errorf("getting crew worker: %w", err)
}
// Ensure crew workspace is on default branch
ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, crewName), r.Path)
// Create tmux session
t := tmux.NewTmux()
sessionID := crewSessionName(rigName, crewName)
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionID, "GT_CREW", crewName)
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
theme := getThemeForRig(rigName)
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, crewName, "crew")
// Set up C-b n/p keybindings for crew session cycling (non-fatal)
_ = t.SetCrewCycleBindings(sessionID)
// Wait for shell to be ready
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude with proper env vars for seance
// Pass "gt prime" as initial prompt so context is loaded immediately
// (SessionStart hook fires, then Claude processes "gt prime" as first user message)
claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, r.Path, "gt prime")
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
return fmt.Errorf("starting claude: %w", err)
// Start handles workspace creation, settings, and session all in one
err = crewMgr.Start(crewName, crew.StartOptions{})
if err != nil && !errors.Is(err, crew.ErrSessionRunning) {
return err
}
return nil