refactor: unify agent startup with Manager pattern

- Create mayor.Manager for mayor lifecycle (Start/Stop/IsRunning/Status)
- Create deacon.Manager for deacon lifecycle with respawn loop
- Move session.Manager to polecat.SessionManager (clearer naming)
- Add zombie session detection for mayor/deacon (kills tmux if Claude dead)
- Remove duplicate session startup code from up.go, start.go, mayor.go
- Rename sessMgr -> polecatMgr for consistency
- Make witness/refinery SessionName() public for status display

All agent types now follow the same Manager pattern:
  mgr := agent.NewManager(...)
  mgr.Start(...)
  mgr.Stop()
  mgr.IsRunning()
  mgr.Status()

🤖 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 22:32:35 -08:00
parent 432d14d9df
commit ea8bef2029
16 changed files with 609 additions and 584 deletions
+55 -148
View File
@@ -1,26 +1,14 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
// getMayorSessionName returns the Mayor session name.
func getMayorSessionName() string {
return session.MayorSessionName()
}
var mayorCmd = &cobra.Command{ var mayorCmd = &cobra.Command{
Use: "mayor", Use: "mayor",
Aliases: []string{"may"}, Aliases: []string{"may"},
@@ -95,21 +83,31 @@ func init() {
rootCmd.AddCommand(mayorCmd) rootCmd.AddCommand(mayorCmd)
} }
func runMayorStart(cmd *cobra.Command, args []string) error { // getMayorManager returns a mayor manager for the current workspace.
t := tmux.NewTmux() func getMayorManager() (*mayor.Manager, error) {
townRoot, err := workspace.FindFromCwdOrError()
sessionName := getMayorSessionName()
// Check if session already exists
running, err := t.HasSession(sessionName)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
} }
if running { return mayor.NewManager(townRoot), nil
}
// getMayorSessionName returns the Mayor session name.
func getMayorSessionName() string {
return mayor.SessionName()
}
func runMayorStart(cmd *cobra.Command, args []string) error {
mgr, err := getMayorManager()
if err != nil {
return err
}
fmt.Println("Starting Mayor session...")
if err := mgr.Start(mayorAgentOverride); err != nil {
if err == mayor.ErrAlreadyRunning {
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, mayorAgentOverride); err != nil {
return err return err
} }
@@ -120,93 +118,18 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
return nil return nil
} }
// startMayorSession creates and initializes the Mayor tmux session.
func startMayorSession(t *tmux.Tmux, sessionName, agentOverride string) error {
// Find workspace root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Mayor runs in mayor/ subdirectory to keep its files (CLAUDE.md, settings)
// separate from child agents that inherit the working directory
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("creating mayor directory: %w", err)
}
// Create session in mayor directory
fmt.Println("Starting Mayor session...")
if err := t.NewSession(sessionName, mayorDir); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "mayor")
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "mayor")
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
// Note: ConfigureGasTownSession includes cycle bindings
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
// Launch Claude - the startup hook handles 'gt prime' automatically
// 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
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)
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: "mayor",
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous coordination.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("mayor", mayorDir)) // Non-fatal
return nil
}
func runMayorStop(cmd *cobra.Command, args []string) error { func runMayorStop(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux() mgr, err := getMayorManager()
sessionName := getMayorSessionName()
// Check if session exists
running, err := t.HasSession(sessionName)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return err
}
if !running {
return errors.New("Mayor session is not running")
} }
fmt.Println("Stopping Mayor session...") fmt.Println("Stopping Mayor session...")
if err := mgr.Stop(); err != nil {
// Try graceful shutdown first (best-effort interrupt) if err == mayor.ErrNotRunning {
_ = t.SendKeysRaw(sessionName, "C-c") return fmt.Errorf("Mayor session is not running")
time.Sleep(100 * time.Millisecond) }
return err
// Kill the session
if err := t.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
} }
fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓")) fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓"))
@@ -214,41 +137,45 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
} }
func runMayorAttach(cmd *cobra.Command, args []string) error { func runMayorAttach(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux() mgr, err := getMayorManager()
if err != nil {
return err
}
sessionName := getMayorSessionName() running, err := mgr.IsRunning()
// Check if session exists
running, err := t.HasSession(sessionName)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return fmt.Errorf("checking session: %w", err)
} }
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, mayorAgentOverride); err != nil { if err := mgr.Start(mayorAgentOverride); err != nil {
return err return err
} }
} }
// Use shared attach helper (smart: links if inside tmux, attaches if outside) // Use shared attach helper (smart: links if inside tmux, attaches if outside)
return attachToTmuxSession(sessionName) return attachToTmuxSession(mgr.SessionName())
} }
func runMayorStatus(cmd *cobra.Command, args []string) error { func runMayorStatus(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux() mgr, err := getMayorManager()
sessionName := getMayorSessionName()
running, err := t.HasSession(sessionName)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return err
}
info, err := mgr.Status()
if err != nil {
if err == mayor.ErrNotRunning {
fmt.Printf("%s Mayor session is %s\n",
style.Dim.Render("○"),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
return nil
}
return fmt.Errorf("checking status: %w", err)
} }
if running {
// Get session info for more details
info, err := t.GetSessionInfo(sessionName)
if err == nil {
status := "detached" status := "detached"
if info.Attached { if info.Attached {
status = "attached" status = "attached"
@@ -259,39 +186,19 @@ func runMayorStatus(cmd *cobra.Command, args []string) error {
fmt.Printf(" Status: %s\n", status) fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Created: %s\n", info.Created) fmt.Printf(" Created: %s\n", info.Created)
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach")) fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach"))
} else {
fmt.Printf("%s Mayor session is %s\n",
style.Bold.Render("●"),
style.Bold.Render("running"))
}
} else {
fmt.Printf("%s Mayor session is %s\n",
style.Dim.Render("○"),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
}
return nil return nil
} }
func runMayorRestart(cmd *cobra.Command, args []string) error { func runMayorRestart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux() mgr, err := getMayorManager()
sessionName := getMayorSessionName()
running, err := t.HasSession(sessionName)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return err
} }
if running { // Stop if running (ignore not-running error)
// Stop the current session (best-effort interrupt before kill) if err := mgr.Stop(); err != nil && err != mayor.ErrNotRunning {
fmt.Println("Stopping Mayor session...") return fmt.Errorf("stopping session: %w", err)
_ = t.SendKeysRaw(sessionName, "C-c")
time.Sleep(100 * time.Millisecond)
if err := t.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
} }
// Start fresh // Start fresh
+10 -11
View File
@@ -15,7 +15,6 @@ import (
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
@@ -361,7 +360,7 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
for _, r := range rigs { for _, r := range rigs {
polecatGit := git.NewGit(r.Path) polecatGit := git.NewGit(r.Path)
mgr := polecat.NewManager(r, polecatGit) mgr := polecat.NewManager(r, polecatGit)
sessMgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
polecats, err := mgr.List() polecats, err := mgr.List()
if err != nil { if err != nil {
@@ -370,7 +369,7 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
} }
for _, p := range polecats { for _, p := range polecats {
running, _ := sessMgr.IsRunning(p.Name) running, _ := polecatMgr.IsRunning(p.Name)
allPolecats = append(allPolecats, PolecatListItem{ allPolecats = append(allPolecats, PolecatListItem{
Rig: r.Name, Rig: r.Name,
Name: p.Name, Name: p.Name,
@@ -525,8 +524,8 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
for _, p := range toRemove { for _, p := range toRemove {
// Check if session is running // Check if session is running
if !polecatForce { if !polecatForce {
sessMgr := session.NewManager(t, p.r) polecatMgr := polecat.NewSessionManager(t, p.r)
running, _ := sessMgr.IsRunning(p.polecatName) running, _ := polecatMgr.IsRunning(p.polecatName)
if running { if running {
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName)) removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName))
continue continue
@@ -682,11 +681,11 @@ func runPolecatStatus(cmd *cobra.Command, args []string) error {
// Get session info // Get session info
t := tmux.NewTmux() t := tmux.NewTmux()
sessMgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
sessInfo, err := sessMgr.Status(polecatName) sessInfo, err := polecatMgr.Status(polecatName)
if err != nil { if err != nil {
// Non-fatal - continue without session info // Non-fatal - continue without session info
sessInfo = &session.Info{ sessInfo = &polecat.SessionInfo{
Polecat: polecatName, Polecat: polecatName,
Running: false, Running: false,
} }
@@ -1415,10 +1414,10 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
} }
// Step 1: Kill session (force mode - no graceful shutdown) // Step 1: Kill session (force mode - no graceful shutdown)
sessMgr := session.NewManager(t, p.r) polecatMgr := polecat.NewSessionManager(t, p.r)
running, _ := sessMgr.IsRunning(p.polecatName) running, _ := polecatMgr.IsRunning(p.polecatName)
if running { if running {
if err := sessMgr.Stop(p.polecatName, true); err != nil { if err := polecatMgr.Stop(p.polecatName, true); err != nil {
fmt.Printf(" %s session kill failed: %v\n", style.Warning.Render("⚠"), err) fmt.Printf(" %s session kill failed: %v\n", style.Warning.Render("⚠"), err)
// Continue anyway - worktree removal will still work // Continue anyway - worktree removal will still work
} else { } else {
+5 -6
View File
@@ -12,7 +12,6 @@ import (
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
@@ -152,13 +151,13 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
// Start session // Start session
t := tmux.NewTmux() t := tmux.NewTmux()
sessMgr := session.NewManager(t, r) polecatSessMgr := polecat.NewSessionManager(t, r)
// Check if already running // Check if already running
running, _ := sessMgr.IsRunning(polecatName) running, _ := polecatSessMgr.IsRunning(polecatName)
if !running { if !running {
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
startOpts := session.StartOptions{ startOpts := polecat.SessionStartOptions{
ClaudeConfigDir: claudeConfigDir, ClaudeConfigDir: claudeConfigDir,
} }
if opts.Agent != "" { if opts.Agent != "" {
@@ -168,13 +167,13 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
} }
startOpts.Command = cmd startOpts.Command = cmd
} }
if err := sessMgr.Start(polecatName, startOpts); err != nil { if err := polecatSessMgr.Start(polecatName, startOpts); err != nil {
return nil, fmt.Errorf("starting session: %w", err) return nil, fmt.Errorf("starting session: %w", err)
} }
} }
// Get session name and pane // Get session name and pane
sessionName := sessMgr.SessionName(polecatName) sessionName := polecatSessMgr.SessionName(polecatName)
pane, err := getSessionPane(sessionName) pane, err := getSessionPane(sessionName)
if err != nil { if err != nil {
return nil, fmt.Errorf("getting pane for %s: %w", sessionName, err) return nil, fmt.Errorf("getting pane for %s: %w", sessionName, err)
+9 -10
View File
@@ -17,7 +17,6 @@ import (
"github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery" "github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/wisp" "github.com/steveyegge/gastown/internal/wisp"
@@ -958,11 +957,11 @@ func runRigShutdown(cmd *cobra.Command, args []string) error {
// 1. Stop all polecat sessions // 1. Stop all polecat sessions
t := tmux.NewTmux() t := tmux.NewTmux()
sessMgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
infos, err := sessMgr.List() infos, err := polecatMgr.List()
if err == nil && len(infos) > 0 { if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos)) fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigShutdownForce); err != nil { if err := polecatMgr.StopAll(rigShutdownForce); err != nil {
errors = append(errors, fmt.Sprintf("polecat sessions: %v", err)) errors = append(errors, fmt.Sprintf("polecat sessions: %v", err))
} }
} }
@@ -1235,11 +1234,11 @@ func runRigStop(cmd *cobra.Command, args []string) error {
// 1. Stop all polecat sessions // 1. Stop all polecat sessions
t := tmux.NewTmux() t := tmux.NewTmux()
sessMgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
infos, err := sessMgr.List() infos, err := polecatMgr.List()
if err == nil && len(infos) > 0 { if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos)) fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigStopForce); err != nil { if err := polecatMgr.StopAll(rigStopForce); err != nil {
errors = append(errors, fmt.Sprintf("polecat sessions: %v", err)) errors = append(errors, fmt.Sprintf("polecat sessions: %v", err))
} }
} }
@@ -1368,11 +1367,11 @@ func runRigRestart(cmd *cobra.Command, args []string) error {
fmt.Printf(" Stopping...\n") fmt.Printf(" Stopping...\n")
// 1. Stop all polecat sessions // 1. Stop all polecat sessions
sessMgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
infos, err := sessMgr.List() infos, err := polecatMgr.List()
if err == nil && len(infos) > 0 { if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos)) fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigRestartForce); err != nil { if err := polecatMgr.StopAll(rigRestartForce); err != nil {
stopErrors = append(stopErrors, fmt.Sprintf("polecat sessions: %v", err)) stopErrors = append(stopErrors, fmt.Sprintf("polecat sessions: %v", err))
} }
} }
+24 -24
View File
@@ -12,8 +12,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest" "github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
@@ -224,16 +224,16 @@ func parseAddress(addr string) (rigName, polecatName string, err error) {
} }
// getSessionManager creates a session manager for the given rig. // getSessionManager creates a session manager for the given rig.
func getSessionManager(rigName string) (*session.Manager, *rig.Rig, error) { func getSessionManager(rigName string) (*polecat.SessionManager, *rig.Rig, error) {
_, r, err := getRig(rigName) _, r, err := getRig(rigName)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
t := tmux.NewTmux() t := tmux.NewTmux()
mgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
return mgr, r, nil return polecatMgr, r, nil
} }
func runSessionStart(cmd *cobra.Command, args []string) error { func runSessionStart(cmd *cobra.Command, args []string) error {
@@ -242,7 +242,7 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
return err return err
} }
mgr, r, err := getSessionManager(rigName) polecatMgr, r, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
@@ -261,12 +261,12 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint)) return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint))
} }
opts := session.StartOptions{ opts := polecat.SessionStartOptions{
Issue: sessionIssue, Issue: sessionIssue,
} }
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
if err := mgr.Start(polecatName, opts); err != nil { if err := polecatMgr.Start(polecatName, opts); err != nil {
return fmt.Errorf("starting session: %w", err) return fmt.Errorf("starting session: %w", err)
} }
@@ -290,7 +290,7 @@ func runSessionStop(cmd *cobra.Command, args []string) error {
return err return err
} }
mgr, _, err := getSessionManager(rigName) polecatMgr, _, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
@@ -300,7 +300,7 @@ func runSessionStop(cmd *cobra.Command, args []string) error {
} else { } else {
fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName) fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName)
} }
if err := mgr.Stop(polecatName, sessionForce); err != nil { if err := polecatMgr.Stop(polecatName, sessionForce); err != nil {
return fmt.Errorf("stopping session: %w", err) return fmt.Errorf("stopping session: %w", err)
} }
@@ -326,13 +326,13 @@ func runSessionAttach(cmd *cobra.Command, args []string) error {
return err return err
} }
mgr, _, err := getSessionManager(rigName) polecatMgr, _, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
// Attach (this replaces the process) // Attach (this replaces the process)
return mgr.Attach(polecatName) return polecatMgr.Attach(polecatName)
} }
// SessionListItem represents a session in list output. // SessionListItem represents a session in list output.
@@ -381,8 +381,8 @@ func runSessionList(cmd *cobra.Command, args []string) error {
var allSessions []SessionListItem var allSessions []SessionListItem
for _, r := range rigs { for _, r := range rigs {
mgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
infos, err := mgr.List() infos, err := polecatMgr.List()
if err != nil { if err != nil {
continue continue
} }
@@ -428,7 +428,7 @@ func runSessionCapture(cmd *cobra.Command, args []string) error {
return err return err
} }
mgr, _, err := getSessionManager(rigName) polecatMgr, _, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
@@ -446,7 +446,7 @@ func runSessionCapture(cmd *cobra.Command, args []string) error {
lines = n lines = n
} }
output, err := mgr.Capture(polecatName, lines) output, err := polecatMgr.Capture(polecatName, lines)
if err != nil { if err != nil {
return fmt.Errorf("capturing output: %w", err) return fmt.Errorf("capturing output: %w", err)
} }
@@ -475,12 +475,12 @@ func runSessionInject(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no message provided (use -m or -f)") return fmt.Errorf("no message provided (use -m or -f)")
} }
mgr, _, err := getSessionManager(rigName) polecatMgr, _, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
if err := mgr.Inject(polecatName, message); err != nil { if err := polecatMgr.Inject(polecatName, message); err != nil {
return fmt.Errorf("injecting message: %w", err) return fmt.Errorf("injecting message: %w", err)
} }
@@ -495,13 +495,13 @@ func runSessionRestart(cmd *cobra.Command, args []string) error {
return err return err
} }
mgr, _, err := getSessionManager(rigName) polecatMgr, _, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
// Check if running // Check if running
running, err := mgr.IsRunning(polecatName) running, err := polecatMgr.IsRunning(polecatName)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return fmt.Errorf("checking session: %w", err)
} }
@@ -513,15 +513,15 @@ func runSessionRestart(cmd *cobra.Command, args []string) error {
} else { } else {
fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName) fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName)
} }
if err := mgr.Stop(polecatName, sessionForce); err != nil { if err := polecatMgr.Stop(polecatName, sessionForce); err != nil {
return fmt.Errorf("stopping session: %w", err) return fmt.Errorf("stopping session: %w", err)
} }
} }
// Start fresh session // Start fresh session
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
opts := session.StartOptions{} opts := polecat.SessionStartOptions{}
if err := mgr.Start(polecatName, opts); err != nil { if err := polecatMgr.Start(polecatName, opts); err != nil {
return fmt.Errorf("starting session: %w", err) return fmt.Errorf("starting session: %w", err)
} }
@@ -537,13 +537,13 @@ func runSessionStatus(cmd *cobra.Command, args []string) error {
return err return err
} }
mgr, _, err := getSessionManager(rigName) polecatMgr, _, err := getSessionManager(rigName)
if err != nil { if err != nil {
return err return err
} }
// Get session info // Get session info
info, err := mgr.Status(polecatName) info, err := polecatMgr.Status(polecatName)
if err != nil { if err != nil {
return fmt.Errorf("getting status: %w", err) return fmt.Errorf("getting status: %w", err)
} }
+13 -15
View File
@@ -14,7 +14,9 @@ import (
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/session"
@@ -160,7 +162,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, startAgentOverride); err != nil { if err := startCoreAgents(townRoot, startAgentOverride); err != nil {
return err return err
} }
@@ -186,33 +188,29 @@ func runStart(cmd *cobra.Command, args []string) error {
return nil return nil
} }
// startCoreAgents starts Mayor and Deacon sessions. // startCoreAgents starts Mayor and Deacon sessions using the Manager pattern.
func startCoreAgents(t *tmux.Tmux, agentOverride string) error { func startCoreAgents(townRoot string, agentOverride string) error {
// Get session names
mayorSession := getMayorSessionName()
deaconSession := getDeaconSessionName()
// Start Mayor first (so Deacon sees it as up) // Start Mayor first (so Deacon sees it as up)
mayorRunning, _ := t.HasSession(mayorSession) mayorMgr := mayor.NewManager(townRoot)
if mayorRunning { if err := mayorMgr.Start(agentOverride); err != nil {
if err == mayor.ErrAlreadyRunning {
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("→"))
if err := startMayorSession(t, mayorSession, agentOverride); err != nil {
return fmt.Errorf("starting Mayor: %w", err) return fmt.Errorf("starting Mayor: %w", err)
} }
} else {
fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓")) fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓"))
} }
// Start Deacon (health monitor) // Start Deacon (health monitor)
deaconRunning, _ := t.HasSession(deaconSession) deaconMgr := deacon.NewManager(townRoot)
if deaconRunning { if err := deaconMgr.Start(); err != nil {
if err == deacon.ErrAlreadyRunning {
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("→"))
if err := startDeaconSession(t, deaconSession, agentOverride); err != nil {
return fmt.Errorf("starting Deacon: %w", err) return fmt.Errorf("starting Deacon: %w", err)
} }
} else {
fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓")) fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓"))
} }
+5 -5
View File
@@ -8,8 +8,8 @@ import (
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog" "github.com/steveyegge/gastown/internal/townlog"
@@ -111,8 +111,8 @@ func runStop(cmd *cobra.Command, args []string) error {
stopped := 0 stopped := 0
for _, r := range rigs { for _, r := range rigs {
mgr := session.NewManager(t, r) polecatMgr := polecat.NewSessionManager(t, r)
infos, err := mgr.List() infos, err := polecatMgr.List()
if err != nil { if err != nil {
continue continue
} }
@@ -125,10 +125,10 @@ func runStop(cmd *cobra.Command, args []string) error {
} }
// Capture output before stopping (best effort) // Capture output before stopping (best effort)
output, _ := mgr.Capture(info.Polecat, 50) output, _ := polecatMgr.Capture(info.Polecat, 50)
// Stop the session // Stop the session
err := mgr.Stop(info.Polecat, force) err := polecatMgr.Stop(info.Polecat, force)
if err != nil { if err != nil {
result.Success = false result.Success = false
result.Error = err.Error() result.Error = err.Error()
+4 -5
View File
@@ -15,7 +15,6 @@ import (
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/swarm" "github.com/steveyegge/gastown/internal/swarm"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
@@ -528,7 +527,7 @@ func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, wor
Title string `json:"title"` Title string `json:"title"`
}) error { //nolint:unparam // error return kept for future use }) error { //nolint:unparam // error return kept for future use
t := tmux.NewTmux() t := tmux.NewTmux()
sessMgr := session.NewManager(t, r) polecatSessMgr := polecat.NewSessionManager(t, r)
polecatGit := git.NewGit(r.Path) polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit) polecatMgr := polecat.NewManager(r, polecatGit)
@@ -556,12 +555,12 @@ func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, wor
} }
// Check if already running // Check if already running
running, _ := sessMgr.IsRunning(worker) running, _ := polecatSessMgr.IsRunning(worker)
if running { if running {
fmt.Printf(" %s already running, injecting task...\n", worker) fmt.Printf(" %s already running, injecting task...\n", worker)
} else { } else {
fmt.Printf(" Starting %s...\n", worker) fmt.Printf(" Starting %s...\n", worker)
if err := sessMgr.Start(worker, session.StartOptions{}); err != nil { if err := polecatSessMgr.Start(worker, polecat.SessionStartOptions{}); err != nil {
style.PrintWarning(" couldn't start %s: %v", worker, err) style.PrintWarning(" couldn't start %s: %v", worker, err)
continue continue
} }
@@ -572,7 +571,7 @@ func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, wor
// Inject work assignment // Inject work assignment
context := fmt.Sprintf("[SWARM] You are part of swarm %s.\n\nAssigned task: %s\nTitle: %s\n\nWork on this task. When complete, commit and signal DONE.", context := fmt.Sprintf("[SWARM] You are part of swarm %s.\n\nAssigned task: %s\nTitle: %s\n\nWork on this task. When complete, commit and signal DONE.",
swarmID, task.ID, task.Title) swarmID, task.ID, task.Title)
if err := sessMgr.Inject(worker, context); err != nil { if err := polecatSessMgr.Inject(worker, context); err != nil {
style.PrintWarning(" couldn't inject to %s: %v", worker, err) style.PrintWarning(" couldn't inject to %s: %v", worker, err)
} else { } else {
fmt.Printf(" %s → %s ✓\n", worker, task.ID) fmt.Printf(" %s → %s ✓\n", worker, task.ID)
+46 -227
View File
@@ -10,13 +10,14 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/daemon" "github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery" "github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness" "github.com/steveyegge/gastown/internal/witness"
@@ -67,7 +68,6 @@ func runUp(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace: %w", err) return fmt.Errorf("not in a Gas Town workspace: %w", err)
} }
t := tmux.NewTmux()
allOK := true allOK := true
// 1. Daemon (Go process) // 1. Daemon (Go process)
@@ -81,36 +81,35 @@ func runUp(cmd *cobra.Command, args []string) error {
} }
} }
// Get session names // 2. Deacon (Claude agent)
deaconSession := getDeaconSessionName() deaconMgr := deacon.NewManager(townRoot)
mayorSession := getMayorSessionName() if err := deaconMgr.Start(); err != nil {
if err == deacon.ErrAlreadyRunning {
// 2. Deacon (Claude agent) - runs from townRoot/deacon/ printStatus("Deacon", true, deaconMgr.SessionName())
deaconDir := filepath.Join(townRoot, "deacon") } else {
if err := ensureSession(t, deaconSession, deaconDir, "deacon"); err != nil {
printStatus("Deacon", false, err.Error()) printStatus("Deacon", false, err.Error())
allOK = false allOK = false
}
} else { } else {
printStatus("Deacon", true, deaconSession) printStatus("Deacon", true, deaconMgr.SessionName())
} }
// 3. Mayor (Claude agent) - runs from townRoot/mayor/ // 3. Mayor (Claude agent)
// IMPORTANT: Both settings.json and CLAUDE.md must be in ~/gt/mayor/, NOT ~/gt/ mayorMgr := mayor.NewManager(townRoot)
// Files at town root would be inherited by ALL agents via directory traversal, if err := mayorMgr.Start(""); err != nil {
// causing crew/polecat/etc to receive Mayor-specific context. if err == mayor.ErrAlreadyRunning {
mayorDir := filepath.Join(townRoot, "mayor") printStatus("Mayor", true, mayorMgr.SessionName())
if err := ensureSession(t, mayorSession, mayorDir, "mayor"); err != nil { } else {
printStatus("Mayor", false, err.Error()) printStatus("Mayor", false, err.Error())
allOK = false allOK = false
}
} else { } else {
printStatus("Mayor", true, mayorSession) printStatus("Mayor", true, mayorMgr.SessionName())
} }
// 4. Witnesses (one per rig) // 4. Witnesses (one per rig)
rigs := discoverRigs(townRoot) rigs := discoverRigs(townRoot)
for _, rigName := range rigs { for _, rigName := range rigs {
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
_, r, err := getRig(rigName) _, r, err := getRig(rigName)
if err != nil { if err != nil {
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
@@ -121,13 +120,13 @@ func runUp(cmd *cobra.Command, args []string) error {
mgr := witness.NewManager(r) mgr := witness.NewManager(r)
if err := mgr.Start(false); err != nil { if err := mgr.Start(false); err != nil {
if err == witness.ErrAlreadyRunning { if err == witness.ErrAlreadyRunning {
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName) printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName())
} else { } else {
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
allOK = false allOK = false
} }
} else { } else {
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName) printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName())
} }
} }
@@ -143,22 +142,20 @@ func runUp(cmd *cobra.Command, args []string) error {
mgr := refinery.NewManager(r) mgr := refinery.NewManager(r)
if err := mgr.Start(false); err != nil { if err := mgr.Start(false); err != nil {
if err == refinery.ErrAlreadyRunning { if err == refinery.ErrAlreadyRunning {
sessionName := fmt.Sprintf("gt-%s-refinery", rigName) printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, mgr.SessionName())
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, sessionName)
} else { } else {
printStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error()) printStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
allOK = false allOK = false
} }
} else { } else {
sessionName := fmt.Sprintf("gt-%s-refinery", rigName) printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, mgr.SessionName())
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, sessionName)
} }
} }
// 6. Crew (if --restore) // 6. Crew (if --restore)
if upRestore { if upRestore {
for _, rigName := range rigs { for _, rigName := range rigs {
crewStarted, crewErrors := startCrewFromSettings(t, townRoot, rigName) crewStarted, crewErrors := startCrewFromSettings(townRoot, rigName)
for _, name := range crewStarted { for _, name := range crewStarted {
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name)) printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name))
} }
@@ -170,7 +167,7 @@ func runUp(cmd *cobra.Command, args []string) error {
// 7. Polecats with pinned work (if --restore) // 7. Polecats with pinned work (if --restore)
for _, rigName := range rigs { for _, rigName := range rigs {
polecatsStarted, polecatErrors := startPolecatsWithWork(t, townRoot, rigName) polecatsStarted, polecatErrors := startPolecatsWithWork(townRoot, rigName)
for _, name := range polecatsStarted { for _, name := range polecatsStarted {
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name)) printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name))
} }
@@ -252,79 +249,6 @@ func ensureDaemon(townRoot string) error {
return nil return nil
} }
// ensureSession starts a Claude session if not running.
func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error {
running, err := t.HasSession(sessionName)
if err != nil {
return err
}
if running {
return nil
}
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(workDir, role); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create session
if err := t.NewSession(sessionName, workDir); err != nil {
return err
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionName, "GT_ROLE", role)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", role)
// Apply theme based on role (non-fatal: theming failure doesn't affect operation)
switch role {
case "mayor":
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
case "deacon":
theme := tmux.DeaconTheme()
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
}
// Launch Claude
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
var claudeCmd string
runtimeCmd := config.GetRuntimeCommand("")
if role == "deacon" {
// Deacon uses respawn loop
claudeCmd = `export GT_ROLE=deacon BD_ACTOR=deacon GIT_AUTHOR_NAME=deacon && while true; do echo "⛪ Starting Deacon session..."; ` + runtimeCmd + `; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
} else {
claudeCmd = config.BuildAgentStartupCommand(role, role, "", "")
}
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
// Note: Deacon respawn loop makes beacon tricky - Claude restarts multiple times
// For non-respawn (mayor), inject beacon
if role != "deacon" {
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionName)
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: role,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
}
return nil
}
// discoverRigs finds all rigs in the town. // discoverRigs finds all rigs in the town.
func discoverRigs(townRoot string) []string { func discoverRigs(townRoot string) []string {
var rigs []string var rigs []string
@@ -377,7 +301,7 @@ func discoverRigs(townRoot string) []string {
// startCrewFromSettings starts crew members based on rig settings. // startCrewFromSettings starts crew members based on rig settings.
// Returns list of started crew names and map of errors. // Returns list of started crew names and map of errors.
func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) { func startCrewFromSettings(townRoot, rigName string) ([]string, map[string]error) {
started := []string{} started := []string{}
errors := map[string]error{} errors := map[string]error{}
@@ -420,24 +344,14 @@ func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
// Parse startup preference and determine which crew to start // Parse startup preference and determine which crew to start
toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames) toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames)
// Start each crew member // Start each crew member using Manager
for _, crewName := range toStart { for _, crewName := range toStart {
sessionName := fmt.Sprintf("gt-%s-crew-%s", rigName, crewName) if err := crewMgr.Start(crewName, crew.StartOptions{}); err != nil {
if err == crew.ErrSessionRunning {
running, err := t.HasSession(sessionName)
if err != nil {
errors[crewName] = err
continue
}
if running {
started = append(started, crewName) started = append(started, crewName)
continue } else {
}
// Start the crew member
crewPath := filepath.Join(rigPath, "crew", crewName)
if err := ensureCrewSession(t, sessionName, crewPath, rigName, crewName); err != nil {
errors[crewName] = err errors[crewName] = err
}
} else { } else {
started = append(started, crewName) started = append(started, crewName)
} }
@@ -509,56 +423,9 @@ func parseCrewStartupPreference(pref string, available []string) []string {
return result return result
} }
// ensureCrewSession starts a crew session.
func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName string) error {
// Create session in crew directory
if err := t.NewSession(sessionName, crewPath); err != nil {
return err
}
// Set environment
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "crew")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "GT_CREW", crewName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply theme (use rig-based theme)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Crew", crewName)
// Launch Claude using runtime config
// crewPath is like ~/gt/gastown/crew/max, so rig path is two dirs up
rigPath := filepath.Dir(filepath.Dir(crewPath))
claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, rigPath, "")
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionName)
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", rigName, crewName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
return nil
}
// startPolecatsWithWork starts polecats that have pinned beads (work attached). // startPolecatsWithWork starts polecats that have pinned beads (work attached).
// Returns list of started polecat names and map of errors. // Returns list of started polecat names and map of errors.
func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) { func startPolecatsWithWork(townRoot, rigName string) ([]string, map[string]error) {
started := []string{} started := []string{}
errors := map[string]error{} errors := map[string]error{}
@@ -572,6 +439,14 @@ func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
return started, errors return started, errors
} }
// Get polecat session manager
_, r, err := getRig(rigName)
if err != nil {
return started, errors
}
t := tmux.NewTmux()
polecatMgr := polecat.NewSessionManager(t, r)
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
@@ -593,22 +468,13 @@ func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
continue continue
} }
// This polecat has work - start it // This polecat has work - start it using SessionManager
sessionName := fmt.Sprintf("gt-%s-polecat-%s", rigName, polecatName) if err := polecatMgr.Start(polecatName, polecat.SessionStartOptions{}); err != nil {
if err == polecat.ErrSessionRunning {
running, err := t.HasSession(sessionName)
if err != nil {
errors[polecatName] = err
continue
}
if running {
started = append(started, polecatName) started = append(started, polecatName)
continue } else {
}
// Start the polecat
if err := ensurePolecatSession(t, sessionName, polecatPath, rigName, polecatName); err != nil {
errors[polecatName] = err errors[polecatName] = err
}
} else { } else {
started = append(started, polecatName) started = append(started, polecatName)
} }
@@ -616,50 +482,3 @@ func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
return started, errors return started, errors
} }
// ensurePolecatSession starts a polecat session.
func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polecatName string) error {
// Create session in polecat directory
if err := t.NewSession(sessionName, polecatPath); err != nil {
return err
}
// Set environment
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "polecat")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "GT_POLECAT", polecatName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply theme (use rig-based theme)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Polecat", polecatName)
// Launch Claude using runtime config
// polecatPath is like ~/gt/gastown/polecats/toast, so rig path is two dirs up
rigPath := filepath.Dir(filepath.Dir(polecatPath))
claudeCmd := config.BuildPolecatStartupCommand(rigName, polecatName, rigPath, "")
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionName)
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "witness",
Topic: "dispatch",
}) // Non-fatal
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package deacon
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
// Common errors
var (
ErrNotRunning = errors.New("deacon not running")
ErrAlreadyRunning = errors.New("deacon already running")
)
// Manager handles deacon lifecycle operations.
type Manager struct {
townRoot string
}
// NewManager creates a new deacon manager for a town.
func NewManager(townRoot string) *Manager {
return &Manager{
townRoot: townRoot,
}
}
// SessionName returns the tmux session name for the deacon.
// This is a package-level function for convenience.
func SessionName() string {
return session.DeaconSessionName()
}
// SessionName returns the tmux session name for the deacon.
func (m *Manager) SessionName() string {
return SessionName()
}
// deaconDir returns the working directory for the deacon.
func (m *Manager) deaconDir() string {
return filepath.Join(m.townRoot, "deacon")
}
// Start starts the deacon session.
// The deacon runs in a respawn loop for automatic recovery.
func (m *Manager) Start() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session already exists
running, _ := t.HasSession(sessionID)
if running {
// Session exists - check if Claude is actually running (healthy vs zombie)
if t.IsClaudeRunning(sessionID) {
return ErrAlreadyRunning
}
// Zombie - tmux alive but Claude dead. Kill and recreate.
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing zombie session: %w", err)
}
}
// Ensure deacon directory exists
deaconDir := m.deaconDir()
if err := os.MkdirAll(deaconDir, 0755); err != nil {
return fmt.Errorf("creating deacon directory: %w", err)
}
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionID, deaconDir); err != nil {
return fmt.Errorf("creating tmux session: %w", err)
}
// Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "deacon")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "deacon")
// Apply Deacon theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.DeaconTheme()
_ = t.ConfigureGasTownSession(sessionID, theme, "", "Deacon", "health-check")
// Launch Claude in a respawn loop for automatic recovery
// The respawn loop ensures the deacon restarts if Claude crashes
runtimeCmd := config.GetRuntimeCommand("")
respawnCmd := fmt.Sprintf(
`export GT_ROLE=deacon BD_ACTOR=deacon GIT_AUTHOR_NAME=deacon && while true; do echo "⛪ Starting Deacon session..."; %s; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`,
runtimeCmd,
)
if err := t.SendKeysDelayed(sessionID, respawnCmd, 200); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err)
}
// Wait for Claude to start (non-fatal)
// Note: Deacon respawn loop makes this tricky - Claude restarts multiple times
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal - try to continue anyway
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionID)
time.Sleep(constants.ShutdownNotifyDelay)
// Note: Deacon doesn't get startup nudge due to respawn loop complexity
// The deacon uses its own patrol pattern defined in its CLAUDE.md/prime
return nil
}
// Stop stops the deacon session.
func (m *Manager) Stop() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session exists
running, err := t.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return ErrNotRunning
}
// Try graceful shutdown first (best-effort interrupt)
_ = t.SendKeysRaw(sessionID, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing session: %w", err)
}
return nil
}
// IsRunning checks if the deacon session is active.
func (m *Manager) IsRunning() (bool, error) {
t := tmux.NewTmux()
return t.HasSession(m.SessionName())
}
// Status returns information about the deacon session.
func (m *Manager) Status() (*tmux.SessionInfo, error) {
t := tmux.NewTmux()
sessionID := m.SessionName()
running, err := t.HasSession(sessionID)
if err != nil {
return nil, fmt.Errorf("checking session: %w", err)
}
if !running {
return nil, ErrNotRunning
}
return t.GetSessionInfo(sessionID)
}
+178
View File
@@ -0,0 +1,178 @@
package mayor
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
// Common errors
var (
ErrNotRunning = errors.New("mayor not running")
ErrAlreadyRunning = errors.New("mayor already running")
)
// Manager handles mayor lifecycle operations.
type Manager struct {
townRoot string
}
// NewManager creates a new mayor manager for a town.
func NewManager(townRoot string) *Manager {
return &Manager{
townRoot: townRoot,
}
}
// SessionName returns the tmux session name for the mayor.
// This is a package-level function for convenience.
func SessionName() string {
return session.MayorSessionName()
}
// SessionName returns the tmux session name for the mayor.
func (m *Manager) SessionName() string {
return SessionName()
}
// mayorDir returns the working directory for the mayor.
func (m *Manager) mayorDir() string {
return filepath.Join(m.townRoot, "mayor")
}
// Start starts the mayor session.
// agentOverride optionally specifies a different agent alias to use.
func (m *Manager) Start(agentOverride string) error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session already exists
running, _ := t.HasSession(sessionID)
if running {
// Session exists - check if Claude is actually running (healthy vs zombie)
if t.IsClaudeRunning(sessionID) {
return ErrAlreadyRunning
}
// Zombie - tmux alive but Claude dead. Kill and recreate.
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing zombie session: %w", err)
}
}
// Ensure mayor directory exists
mayorDir := m.mayorDir()
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("creating mayor directory: %w", err)
}
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(mayorDir, "mayor"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionID, mayorDir); err != nil {
return fmt.Errorf("creating tmux session: %w", err)
}
// Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "mayor")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "mayor")
// Apply Mayor theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(sessionID, theme, "", "Mayor", "coordinator")
// Launch Claude - the startup hook handles 'gt prime' automatically
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", agentOverride)
if err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("building startup command: %w", err)
}
if err := t.SendKeysDelayed(sessionID, startupCmd, 200); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err)
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal - try to continue anyway
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionID)
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
Recipient: "mayor",
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous coordination.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("mayor", mayorDir)) // Non-fatal
return nil
}
// Stop stops the mayor session.
func (m *Manager) Stop() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session exists
running, err := t.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return ErrNotRunning
}
// Try graceful shutdown first (best-effort interrupt)
_ = t.SendKeysRaw(sessionID, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing session: %w", err)
}
return nil
}
// IsRunning checks if the mayor session is active.
func (m *Manager) IsRunning() (bool, error) {
t := tmux.NewTmux()
return t.HasSession(m.SessionName())
}
// Status returns information about the mayor session.
func (m *Manager) Status() (*tmux.SessionInfo, error) {
t := tmux.NewTmux()
sessionID := m.SessionName()
running, err := t.HasSession(sessionID)
if err != nil {
return nil, fmt.Errorf("checking session: %w", err)
}
if !running {
return nil, ErrNotRunning
}
return t.GetSessionInfo(sessionID)
}
@@ -1,5 +1,5 @@
// Package session provides polecat session lifecycle management. // Package polecat provides polecat workspace and session management.
package session package polecat
import ( import (
"errors" "errors"
@@ -14,41 +14,39 @@ import (
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
// debugSession logs non-fatal errors during session startup when GT_DEBUG_SESSION=1. // debugSession logs non-fatal errors during session startup when GT_DEBUG_SESSION=1.
// These errors are intentionally suppressed because they don't prevent session operation,
// but logging them helps diagnose issues when needed.
func debugSession(context string, err error) { func debugSession(context string, err error) {
if os.Getenv("GT_DEBUG_SESSION") != "" && err != nil { if os.Getenv("GT_DEBUG_SESSION") != "" && err != nil {
fmt.Fprintf(os.Stderr, "[session-debug] %s: %v\n", context, err) fmt.Fprintf(os.Stderr, "[session-debug] %s: %v\n", context, err)
} }
} }
// Common errors // Session errors
var ( var (
ErrSessionRunning = errors.New("session already running") ErrSessionRunning = errors.New("session already running")
ErrSessionNotFound = errors.New("session not found") ErrSessionNotFound = errors.New("session not found")
ErrPolecatNotFound = errors.New("polecat not found")
) )
// Manager handles polecat session lifecycle. // SessionManager handles polecat session lifecycle.
type Manager struct { type SessionManager struct {
tmux *tmux.Tmux tmux *tmux.Tmux
rig *rig.Rig rig *rig.Rig
} }
// NewManager creates a new session manager for a rig. // NewSessionManager creates a new polecat session manager for a rig.
func NewManager(t *tmux.Tmux, r *rig.Rig) *Manager { func NewSessionManager(t *tmux.Tmux, r *rig.Rig) *SessionManager {
return &Manager{ return &SessionManager{
tmux: t, tmux: t,
rig: r, rig: r,
} }
} }
// StartOptions configures session startup. // SessionStartOptions configures polecat session startup.
type StartOptions struct { type SessionStartOptions struct {
// WorkDir overrides the default working directory (polecat clone dir). // WorkDir overrides the default working directory (polecat clone dir).
WorkDir string WorkDir string
@@ -66,8 +64,8 @@ type StartOptions struct {
ClaudeConfigDir string ClaudeConfigDir string
} }
// Info contains information about a running session. // SessionInfo contains information about a running polecat session.
type Info struct { type SessionInfo struct {
// Polecat is the polecat name. // Polecat is the polecat name.
Polecat string `json:"polecat"` Polecat string `json:"polecat"`
@@ -94,18 +92,17 @@ type Info struct {
} }
// SessionName generates the tmux session name for a polecat. // SessionName generates the tmux session name for a polecat.
func (m *Manager) SessionName(polecat string) string { func (m *SessionManager) SessionName(polecat string) string {
return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat) return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat)
} }
// polecatDir returns the working directory for a polecat. // polecatDir returns the working directory for a polecat.
func (m *Manager) polecatDir(polecat string) string { func (m *SessionManager) polecatDir(polecat string) string {
return filepath.Join(m.rig.Path, "polecats", polecat) return filepath.Join(m.rig.Path, "polecats", polecat)
} }
// hasPolecat checks if the polecat exists in this rig. // hasPolecat checks if the polecat exists in this rig.
func (m *Manager) hasPolecat(polecat string) bool { func (m *SessionManager) hasPolecat(polecat string) bool {
// Check filesystem directly to handle newly-created polecats
polecatPath := m.polecatDir(polecat) polecatPath := m.polecatDir(polecat)
info, err := os.Stat(polecatPath) info, err := os.Stat(polecatPath)
if err != nil { if err != nil {
@@ -115,7 +112,7 @@ func (m *Manager) hasPolecat(polecat string) bool {
} }
// Start creates and starts a new session for a polecat. // Start creates and starts a new session for a polecat.
func (m *Manager) Start(polecat string, opts StartOptions) error { func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
if !m.hasPolecat(polecat) { if !m.hasPolecat(polecat) {
return fmt.Errorf("%w: %s", ErrPolecatNotFound, polecat) return fmt.Errorf("%w: %s", ErrPolecatNotFound, polecat)
} }
@@ -139,7 +136,6 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't // Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
// write into the source repo. Claude walks up the tree to find settings. // write into the source repo. Claude walks up the tree to find settings.
// All polecats share the same settings file.
polecatsDir := filepath.Join(m.rig.Path, "polecats") polecatsDir := filepath.Join(m.rig.Path, "polecats")
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil { if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err) return fmt.Errorf("ensuring Claude settings: %w", err)
@@ -159,12 +155,8 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir)) debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir))
} }
// CRITICAL: Set beads environment for worktree polecats (non-fatal: session works without) // Set beads environment for worktree polecats (non-fatal)
// Polecats need access to TOWN-level beads (parent of rig) for hooks and convoys. townRoot := filepath.Dir(m.rig.Path)
// Town beads use hq- prefix and store hooks, mail, and cross-rig coordination.
// BEADS_NO_DAEMON=1 prevents daemon from committing to wrong branch.
// Using town-level beads ensures gt prime and bd commands can find hooked work.
townRoot := filepath.Dir(m.rig.Path) // Town root is parent of rig directory
beadsDir := filepath.Join(townRoot, ".beads") beadsDir := filepath.Join(townRoot, ".beads")
debugSession("SetEnvironment BEADS_DIR", m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir)) debugSession("SetEnvironment BEADS_DIR", m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir))
debugSession("SetEnvironment BEADS_NO_DAEMON", m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")) debugSession("SetEnvironment BEADS_NO_DAEMON", m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1"))
@@ -174,12 +166,11 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
if opts.Issue != "" { if opts.Issue != "" {
agentID := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat) agentID := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
if err := m.hookIssue(opts.Issue, agentID, workDir); err != nil { if err := m.hookIssue(opts.Issue, agentID, workDir); err != nil {
// Non-fatal - warn but continue (session can still start)
fmt.Printf("Warning: could not hook issue %s: %v\n", opts.Issue, err) fmt.Printf("Warning: could not hook issue %s: %v\n", opts.Issue, err)
} }
} }
// Apply theme (non-fatal: theming failure doesn't affect operation) // Apply theme (non-fatal)
theme := tmux.AssignTheme(m.rig.Name) theme := tmux.AssignTheme(m.rig.Name)
debugSession("ConfigureGasTownSession", m.tmux.ConfigureGasTownSession(sessionID, theme, m.rig.Name, polecat, "polecat")) debugSession("ConfigureGasTownSession", m.tmux.ConfigureGasTownSession(sessionID, theme, m.rig.Name, polecat, "polecat"))
@@ -188,61 +179,43 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
debugSession("SetPaneDiedHook", m.tmux.SetPaneDiedHook(sessionID, agentID)) debugSession("SetPaneDiedHook", m.tmux.SetPaneDiedHook(sessionID, agentID))
// Send initial command with env vars exported inline // Send initial command with env vars exported inline
// NOTE: tmux SetEnvironment only affects NEW panes, not the current shell.
// We must export GT_ROLE, GT_RIG, GT_POLECAT inline for Claude to detect identity.
command := opts.Command command := opts.Command
if command == "" { if command == "" {
// Polecats run with full permissions - Gas Town is for grownups
// Export env vars inline so Claude's role detection works
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "") command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
} }
if err := m.tmux.SendKeys(sessionID, command); err != nil { if err := m.tmux.SendKeys(sessionID, command); err != nil {
return fmt.Errorf("sending command: %w", err) return fmt.Errorf("sending command: %w", err)
} }
// Wait for Claude to start (non-fatal: session continues even if this times out) // Wait for Claude to start (non-fatal)
debugSession("WaitForCommand", m.tmux.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout)) debugSession("WaitForCommand", m.tmux.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout))
// Accept bypass permissions warning dialog if it appears. // Accept bypass permissions warning dialog if it appears
// When Claude starts with --dangerously-skip-permissions, it shows a warning that
// requires pressing Down to select "Yes, I accept" and Enter to confirm.
// This is needed for automated polecat startup.
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID)) debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
// Wait for Claude to be fully ready at the prompt (not just started) // Wait for Claude to be fully ready
// PRAGMATIC APPROACH: Use fixed delay rather than detection.
// WaitForClaudeReady has false positives (detects > in various contexts).
// Claude startup takes ~5-8 seconds on typical machines.
// Reduced from 10s to 8s since AcceptBypassPermissionsWarning already adds ~1.2s.
time.Sleep(8 * time.Second) time.Sleep(8 * time.Second)
// Inject startup nudge for predecessor discovery via /resume // Inject startup nudge for predecessor discovery via /resume
// This becomes the session title in Claude Code's session picker
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat) address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
debugSession("StartupNudge", StartupNudge(m.tmux, sessionID, StartupNudgeConfig{ debugSession("StartupNudge", session.StartupNudge(m.tmux, sessionID, session.StartupNudgeConfig{
Recipient: address, Recipient: address,
Sender: "witness", Sender: "witness",
Topic: "assigned", Topic: "assigned",
MolID: opts.Issue, MolID: opts.Issue,
})) }))
// GUPP: Gas Town Universal Propulsion Principle // GUPP: Send propulsion nudge to trigger autonomous work execution
// Send the propulsion nudge to trigger autonomous work execution.
// The beacon alone is just metadata - this nudge is the actual instruction
// that triggers Claude to check the hook and begin work.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, PropulsionNudge())) debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, session.PropulsionNudge()))
return nil return nil
} }
// Stop terminates a polecat session. // Stop terminates a polecat session.
// If force is true, skips graceful shutdown and kills immediately. func (m *SessionManager) Stop(polecat string, force bool) error {
func (m *Manager) Stop(polecat string, force bool) error {
sessionID := m.SessionName(polecat) sessionID := m.SessionName(polecat)
// Check if session exists
running, err := m.tmux.HasSession(sessionID) running, err := m.tmux.HasSession(sessionID)
if err != nil { if err != nil {
return fmt.Errorf("checking session: %w", err) return fmt.Errorf("checking session: %w", err)
@@ -251,23 +224,20 @@ func (m *Manager) Stop(polecat string, force bool) error {
return ErrSessionNotFound return ErrSessionNotFound
} }
// Sync beads before shutdown to preserve any changes // Sync beads before shutdown (non-fatal)
// Run in the polecat's worktree directory
if !force { if !force {
polecatDir := m.polecatDir(polecat) polecatDir := m.polecatDir(polecat)
if err := m.syncBeads(polecatDir); err != nil { if err := m.syncBeads(polecatDir); err != nil {
// Non-fatal - log and continue with shutdown
fmt.Printf("Warning: beads sync failed: %v\n", err) fmt.Printf("Warning: beads sync failed: %v\n", err)
} }
} }
// Try graceful shutdown first (unless forced, best-effort interrupt) // Try graceful shutdown first
if !force { if !force {
_ = m.tmux.SendKeysRaw(sessionID, "C-c") _ = m.tmux.SendKeysRaw(sessionID, "C-c")
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
// Kill the session
if err := m.tmux.KillSession(sessionID); err != nil { if err := m.tmux.KillSession(sessionID); err != nil {
return fmt.Errorf("killing session: %w", err) return fmt.Errorf("killing session: %w", err)
} }
@@ -276,20 +246,20 @@ func (m *Manager) Stop(polecat string, force bool) error {
} }
// syncBeads runs bd sync in the given directory. // syncBeads runs bd sync in the given directory.
func (m *Manager) syncBeads(workDir string) error { func (m *SessionManager) syncBeads(workDir string) error {
cmd := exec.Command("bd", "sync") cmd := exec.Command("bd", "sync")
cmd.Dir = workDir cmd.Dir = workDir
return cmd.Run() return cmd.Run()
} }
// IsRunning checks if a polecat session is active. // IsRunning checks if a polecat session is active.
func (m *Manager) IsRunning(polecat string) (bool, error) { func (m *SessionManager) IsRunning(polecat string) (bool, error) {
sessionID := m.SessionName(polecat) sessionID := m.SessionName(polecat)
return m.tmux.HasSession(sessionID) return m.tmux.HasSession(sessionID)
} }
// Status returns detailed status for a polecat session. // Status returns detailed status for a polecat session.
func (m *Manager) Status(polecat string) (*Info, error) { func (m *SessionManager) Status(polecat string) (*SessionInfo, error) {
sessionID := m.SessionName(polecat) sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID) running, err := m.tmux.HasSession(sessionID)
@@ -297,7 +267,7 @@ func (m *Manager) Status(polecat string) (*Info, error) {
return nil, fmt.Errorf("checking session: %w", err) return nil, fmt.Errorf("checking session: %w", err)
} }
info := &Info{ info := &SessionInfo{
Polecat: polecat, Polecat: polecat,
SessionID: sessionID, SessionID: sessionID,
Running: running, Running: running,
@@ -308,19 +278,15 @@ func (m *Manager) Status(polecat string) (*Info, error) {
return info, nil return info, nil
} }
// Get detailed session info
tmuxInfo, err := m.tmux.GetSessionInfo(sessionID) tmuxInfo, err := m.tmux.GetSessionInfo(sessionID)
if err != nil { if err != nil {
// Non-fatal - return basic info
return info, nil return info, nil
} }
info.Attached = tmuxInfo.Attached info.Attached = tmuxInfo.Attached
info.Windows = tmuxInfo.Windows info.Windows = tmuxInfo.Windows
// Parse created time from tmux format (e.g., "Thu Dec 19 10:30:00 2025")
if tmuxInfo.Created != "" { if tmuxInfo.Created != "" {
// Try common tmux date formats
formats := []string{ formats := []string{
"Mon Jan 2 15:04:05 2006", "Mon Jan 2 15:04:05 2006",
"Mon Jan _2 15:04:05 2006", "Mon Jan _2 15:04:05 2006",
@@ -335,7 +301,6 @@ func (m *Manager) Status(polecat string) (*Info, error) {
} }
} }
// Parse activity time (unix timestamp from tmux)
if tmuxInfo.Activity != "" { if tmuxInfo.Activity != "" {
var activityUnix int64 var activityUnix int64
if _, err := fmt.Sscanf(tmuxInfo.Activity, "%d", &activityUnix); err == nil && activityUnix > 0 { if _, err := fmt.Sscanf(tmuxInfo.Activity, "%d", &activityUnix); err == nil && activityUnix > 0 {
@@ -346,15 +311,15 @@ func (m *Manager) Status(polecat string) (*Info, error) {
return info, nil return info, nil
} }
// List returns information about all sessions for this rig. // List returns information about all polecat sessions for this rig.
func (m *Manager) List() ([]Info, error) { func (m *SessionManager) List() ([]SessionInfo, error) {
sessions, err := m.tmux.ListSessions() sessions, err := m.tmux.ListSessions()
if err != nil { if err != nil {
return nil, err return nil, err
} }
prefix := fmt.Sprintf("gt-%s-", m.rig.Name) prefix := fmt.Sprintf("gt-%s-", m.rig.Name)
var infos []Info var infos []SessionInfo
for _, sessionID := range sessions { for _, sessionID := range sessions {
if !strings.HasPrefix(sessionID, prefix) { if !strings.HasPrefix(sessionID, prefix) {
@@ -362,7 +327,7 @@ func (m *Manager) List() ([]Info, error) {
} }
polecat := strings.TrimPrefix(sessionID, prefix) polecat := strings.TrimPrefix(sessionID, prefix)
infos = append(infos, Info{ infos = append(infos, SessionInfo{
Polecat: polecat, Polecat: polecat,
SessionID: sessionID, SessionID: sessionID,
Running: true, Running: true,
@@ -374,7 +339,7 @@ func (m *Manager) List() ([]Info, error) {
} }
// Attach attaches to a polecat session. // Attach attaches to a polecat session.
func (m *Manager) Attach(polecat string) error { func (m *SessionManager) Attach(polecat string) error {
sessionID := m.SessionName(polecat) sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID) running, err := m.tmux.HasSession(sessionID)
@@ -389,7 +354,7 @@ func (m *Manager) Attach(polecat string) error {
} }
// Capture returns the recent output from a polecat session. // Capture returns the recent output from a polecat session.
func (m *Manager) Capture(polecat string, lines int) (string, error) { func (m *SessionManager) Capture(polecat string, lines int) (string, error) {
sessionID := m.SessionName(polecat) sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID) running, err := m.tmux.HasSession(sessionID)
@@ -404,9 +369,7 @@ func (m *Manager) Capture(polecat string, lines int) (string, error) {
} }
// CaptureSession returns the recent output from a session by raw session ID. // CaptureSession returns the recent output from a session by raw session ID.
// Use this for crew workers or other non-polecat sessions where the session func (m *SessionManager) CaptureSession(sessionID string, lines int) (string, error) {
// name doesn't follow the standard gt-{rig}-{polecat} pattern.
func (m *Manager) CaptureSession(sessionID string, lines int) (string, error) {
running, err := m.tmux.HasSession(sessionID) running, err := m.tmux.HasSession(sessionID)
if err != nil { if err != nil {
return "", fmt.Errorf("checking session: %w", err) return "", fmt.Errorf("checking session: %w", err)
@@ -419,8 +382,7 @@ func (m *Manager) CaptureSession(sessionID string, lines int) (string, error) {
} }
// Inject sends a message to a polecat session. // Inject sends a message to a polecat session.
// Uses a longer debounce delay for large messages to ensure paste completes. func (m *SessionManager) Inject(polecat, message string) error {
func (m *Manager) Inject(polecat, message string) error {
sessionID := m.SessionName(polecat) sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID) running, err := m.tmux.HasSession(sessionID)
@@ -431,19 +393,16 @@ func (m *Manager) Inject(polecat, message string) error {
return ErrSessionNotFound return ErrSessionNotFound
} }
// Use longer debounce for large messages (spawn context can be 1KB+)
// Claude needs time to process paste before Enter is sent
// Scale delay based on message size: 200ms base + 100ms per KB
debounceMs := 200 + (len(message)/1024)*100 debounceMs := 200 + (len(message)/1024)*100
if debounceMs > 1500 { if debounceMs > 1500 {
debounceMs = 1500 // Cap at 1.5s for large pastes debounceMs = 1500
} }
return m.tmux.SendKeysDebounced(sessionID, message, debounceMs) return m.tmux.SendKeysDebounced(sessionID, message, debounceMs)
} }
// StopAll terminates all sessions for this rig. // StopAll terminates all polecat sessions for this rig.
func (m *Manager) StopAll(force bool) error { func (m *SessionManager) StopAll(force bool) error {
infos, err := m.List() infos, err := m.List()
if err != nil { if err != nil {
return err return err
@@ -460,10 +419,8 @@ func (m *Manager) StopAll(force bool) error {
} }
// hookIssue pins an issue to a polecat's hook using bd update. // hookIssue pins an issue to a polecat's hook using bd update.
// This makes the work visible via 'gt hook' when the session starts. func (m *SessionManager) hookIssue(issueID, agentID, workDir string) error {
func (m *Manager) hookIssue(issueID, agentID, workDir string) error { cmd := exec.Command("bd", "update", issueID, "--status=hooked", "--assignee="+agentID) //nolint:gosec
// Use bd update to set status=hooked and assign to the polecat
cmd := exec.Command("bd", "update", issueID, "--status=hooked", "--assignee="+agentID) //nolint:gosec // G204: bd is a trusted internal tool
cmd.Dir = workDir cmd.Dir = workDir
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
@@ -1,4 +1,4 @@
package session package polecat
import ( import (
"os" "os"
@@ -15,7 +15,7 @@ func TestSessionName(t *testing.T) {
Name: "gastown", Name: "gastown",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
name := m.SessionName("Toast") name := m.SessionName("Toast")
if name != "gt-gastown-Toast" { if name != "gt-gastown-Toast" {
@@ -23,13 +23,13 @@ func TestSessionName(t *testing.T) {
} }
} }
func TestPolecatDir(t *testing.T) { func TestSessionManagerPolecatDir(t *testing.T) {
r := &rig.Rig{ r := &rig.Rig{
Name: "gastown", Name: "gastown",
Path: "/home/user/ai/gastown", Path: "/home/user/ai/gastown",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
dir := m.polecatDir("Toast") dir := m.polecatDir("Toast")
expected := "/home/user/ai/gastown/polecats/Toast" expected := "/home/user/ai/gastown/polecats/Toast"
@@ -52,7 +52,7 @@ func TestHasPolecat(t *testing.T) {
Path: root, Path: root,
Polecats: []string{"Toast", "Cheedo"}, Polecats: []string{"Toast", "Cheedo"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
if !m.hasPolecat("Toast") { if !m.hasPolecat("Toast") {
t.Error("expected hasPolecat(Toast) = true") t.Error("expected hasPolecat(Toast) = true")
@@ -70,9 +70,9 @@ func TestStartPolecatNotFound(t *testing.T) {
Name: "gastown", Name: "gastown",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
err := m.Start("Unknown", StartOptions{}) err := m.Start("Unknown", SessionStartOptions{})
if err == nil { if err == nil {
t.Error("expected error for unknown polecat") t.Error("expected error for unknown polecat")
} }
@@ -83,7 +83,7 @@ func TestIsRunningNoSession(t *testing.T) {
Name: "gastown", Name: "gastown",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
running, err := m.IsRunning("Toast") running, err := m.IsRunning("Toast")
if err != nil { if err != nil {
@@ -94,12 +94,12 @@ func TestIsRunningNoSession(t *testing.T) {
} }
} }
func TestListEmpty(t *testing.T) { func TestSessionManagerListEmpty(t *testing.T) {
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig-unlikely-name", Name: "test-rig-unlikely-name",
Polecats: []string{}, Polecats: []string{},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
infos, err := m.List() infos, err := m.List()
if err != nil { if err != nil {
@@ -115,7 +115,7 @@ func TestStopNotFound(t *testing.T) {
Name: "test-rig", Name: "test-rig",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
err := m.Stop("Toast", false) err := m.Stop("Toast", false)
if err != ErrSessionNotFound { if err != ErrSessionNotFound {
@@ -128,7 +128,7 @@ func TestCaptureNotFound(t *testing.T) {
Name: "test-rig", Name: "test-rig",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
_, err := m.Capture("Toast", 50) _, err := m.Capture("Toast", 50)
if err != ErrSessionNotFound { if err != ErrSessionNotFound {
@@ -141,7 +141,7 @@ func TestInjectNotFound(t *testing.T) {
Name: "test-rig", Name: "test-rig",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
} }
m := NewManager(tmux.NewTmux(), r) m := NewSessionManager(tmux.NewTmux(), r)
err := m.Inject("Toast", "hello") err := m.Inject("Toast", "hello")
if err != ErrSessionNotFound { if err != ErrSessionNotFound {
+4 -4
View File
@@ -58,8 +58,8 @@ func (m *Manager) stateFile() string {
return filepath.Join(m.rig.Path, ".runtime", "refinery.json") return filepath.Join(m.rig.Path, ".runtime", "refinery.json")
} }
// sessionName returns the tmux session name for this refinery. // SessionName returns the tmux session name for this refinery.
func (m *Manager) sessionName() string { func (m *Manager) SessionName() string {
return fmt.Sprintf("gt-%s-refinery", m.rig.Name) return fmt.Sprintf("gt-%s-refinery", m.rig.Name)
} }
@@ -111,7 +111,7 @@ func (m *Manager) Start(foreground bool) error {
} }
t := tmux.NewTmux() t := tmux.NewTmux()
sessionID := m.sessionName() sessionID := m.SessionName()
if foreground { if foreground {
// In foreground mode, we're likely running inside the tmux session // In foreground mode, we're likely running inside the tmux session
@@ -253,7 +253,7 @@ func (m *Manager) Stop() error {
// Check if tmux session exists // Check if tmux session exists
t := tmux.NewTmux() t := tmux.NewTmux()
sessionID := m.sessionName() sessionID := m.SessionName()
sessionRunning, _ := t.HasSession(sessionID) sessionRunning, _ := t.HasSession(sessionID)
// If neither state nor session indicates running, it's not running // If neither state nor session indicates running, it's not running
+4 -4
View File
@@ -8,7 +8,7 @@ import (
"time" "time"
"github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
@@ -59,12 +59,12 @@ func (m *Manager) ExecuteLanding(swarmID string, config LandingConfig) (*Landing
// Phase 1: Stop all polecat sessions // Phase 1: Stop all polecat sessions
t := tmux.NewTmux() t := tmux.NewTmux()
sessMgr := session.NewManager(t, m.rig) polecatMgr := polecat.NewSessionManager(t, m.rig)
for _, worker := range swarm.Workers { for _, worker := range swarm.Workers {
running, _ := sessMgr.IsRunning(worker) running, _ := polecatMgr.IsRunning(worker)
if running { if running {
err := sessMgr.Stop(worker, config.ForceKill) err := polecatMgr.Stop(worker, config.ForceKill)
if err != nil { if err != nil {
// Continue anyway // Continue anyway
} else { } else {
+4 -4
View File
@@ -59,8 +59,8 @@ func (m *Manager) saveState(w *Witness) error {
return m.stateManager.Save(w) return m.stateManager.Save(w)
} }
// sessionName returns the tmux session name for this witness. // SessionName returns the tmux session name for this witness.
func (m *Manager) sessionName() string { func (m *Manager) SessionName() string {
return fmt.Sprintf("gt-%s-witness", m.rig.Name) return fmt.Sprintf("gt-%s-witness", m.rig.Name)
} }
@@ -105,7 +105,7 @@ func (m *Manager) Start(foreground bool) error {
} }
t := tmux.NewTmux() t := tmux.NewTmux()
sessionID := m.sessionName() sessionID := m.SessionName()
if foreground { if foreground {
// Foreground mode is deprecated - patrol logic moved to mol-witness-patrol // Foreground mode is deprecated - patrol logic moved to mol-witness-patrol
@@ -224,7 +224,7 @@ func (m *Manager) Stop() error {
// Check if tmux session exists // Check if tmux session exists
t := tmux.NewTmux() t := tmux.NewTmux()
sessionID := m.sessionName() sessionID := m.SessionName()
sessionRunning, _ := t.HasSession(sessionID) sessionRunning, _ := t.HasSession(sessionID)
// If neither state nor session indicates running, it's not running // If neither state nor session indicates running, it's not running