Witness and Refinery startup was duplicated across cmd/witness.go, cmd/up.go, cmd/rig.go, and daemon.go. Worse, not all code paths sent the propulsion nudge (GUPP - Gas Town Universal Propulsion Principle). Now unified in Manager.Start() which handles everything including nudges. Changes: - witness/manager.go: Full rewrite with session creation, env vars, theming, WaitForClaudeReady, startup nudge, and propulsion nudge (GUPP) - refinery/manager.go: Add propulsion nudge sequence after Claude startup - cmd/witness.go: Simplify to just call mgr.Start(), remove ensureWitnessSession - cmd/rig.go: Use witness.Manager.Start() instead of inline session creation - cmd/start.go: Use witness.Manager.Start() - cmd/up.go: Use witness.Manager.Start(), remove ensureWitness(), add EnsureSettingsForRole in ensureSession() - daemon.go: Use witness.Manager.Start() and refinery.Manager.Start() for unified startup with proper nudges This ensures all agent startup paths (gt witness start, gt rig boot, gt up, daemon restarts) consistently apply GUPP propulsion nudges. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
661 lines
18 KiB
Go
661 lines
18 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/claude"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/daemon"
|
|
"github.com/steveyegge/gastown/internal/events"
|
|
"github.com/steveyegge/gastown/internal/refinery"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/witness"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
var upCmd = &cobra.Command{
|
|
Use: "up",
|
|
GroupID: GroupServices,
|
|
Short: "Bring up all Gas Town services",
|
|
Long: `Start all Gas Town long-lived services.
|
|
|
|
This is the idempotent "boot" command for Gas Town. It ensures all
|
|
infrastructure agents are running:
|
|
|
|
• Daemon - Go background process that pokes agents
|
|
• Deacon - Health orchestrator (monitors Mayor/Witnesses)
|
|
• Mayor - Global work coordinator
|
|
• Witnesses - Per-rig polecat managers
|
|
• Refineries - Per-rig merge queue processors
|
|
|
|
Polecats are NOT started by this command - they are transient workers
|
|
spawned on demand by the Mayor or Witnesses.
|
|
|
|
Use --restore to also start:
|
|
• Crew - Per rig settings (settings/config.json crew.startup)
|
|
• Polecats - Those with pinned beads (work attached)
|
|
|
|
Running 'gt up' multiple times is safe - it only starts services that
|
|
aren't already running.`,
|
|
RunE: runUp,
|
|
}
|
|
|
|
var (
|
|
upQuiet bool
|
|
upRestore bool
|
|
)
|
|
|
|
func init() {
|
|
upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors")
|
|
upCmd.Flags().BoolVar(&upRestore, "restore", false, "Also restore crew (from settings) and polecats (from hooks)")
|
|
rootCmd.AddCommand(upCmd)
|
|
}
|
|
|
|
func runUp(cmd *cobra.Command, args []string) error {
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
t := tmux.NewTmux()
|
|
allOK := true
|
|
|
|
// 1. Daemon (Go process)
|
|
if err := ensureDaemon(townRoot); err != nil {
|
|
printStatus("Daemon", false, err.Error())
|
|
allOK = false
|
|
} else {
|
|
running, pid, _ := daemon.IsRunning(townRoot)
|
|
if running {
|
|
printStatus("Daemon", true, fmt.Sprintf("PID %d", pid))
|
|
}
|
|
}
|
|
|
|
// Get session names
|
|
deaconSession := getDeaconSessionName()
|
|
mayorSession := getMayorSessionName()
|
|
|
|
// 2. Deacon (Claude agent)
|
|
if err := ensureSession(t, deaconSession, townRoot, "deacon"); err != nil {
|
|
printStatus("Deacon", false, err.Error())
|
|
allOK = false
|
|
} else {
|
|
printStatus("Deacon", true, deaconSession)
|
|
}
|
|
|
|
// 3. Mayor (Claude agent)
|
|
if err := ensureSession(t, mayorSession, townRoot, "mayor"); err != nil {
|
|
printStatus("Mayor", false, err.Error())
|
|
allOK = false
|
|
} else {
|
|
printStatus("Mayor", true, mayorSession)
|
|
}
|
|
|
|
// 4. Witnesses (one per rig)
|
|
rigs := discoverRigs(townRoot)
|
|
for _, rigName := range rigs {
|
|
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
|
|
|
_, r, err := getRig(rigName)
|
|
if err != nil {
|
|
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
|
allOK = false
|
|
continue
|
|
}
|
|
|
|
mgr := witness.NewManager(r)
|
|
if err := mgr.Start(false); err != nil {
|
|
if err == witness.ErrAlreadyRunning {
|
|
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName)
|
|
} else {
|
|
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
|
allOK = false
|
|
}
|
|
} else {
|
|
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName)
|
|
}
|
|
}
|
|
|
|
// 5. Refineries (one per rig)
|
|
for _, rigName := range rigs {
|
|
_, r, err := getRig(rigName)
|
|
if err != nil {
|
|
printStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
|
|
allOK = false
|
|
continue
|
|
}
|
|
|
|
mgr := refinery.NewManager(r)
|
|
if err := mgr.Start(false); err != nil {
|
|
if err == refinery.ErrAlreadyRunning {
|
|
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
|
|
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, sessionName)
|
|
} else {
|
|
printStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
|
|
allOK = false
|
|
}
|
|
} else {
|
|
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
|
|
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, sessionName)
|
|
}
|
|
}
|
|
|
|
// 6. Crew (if --restore)
|
|
if upRestore {
|
|
for _, rigName := range rigs {
|
|
crewStarted, crewErrors := startCrewFromSettings(t, townRoot, rigName)
|
|
for _, name := range crewStarted {
|
|
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name))
|
|
}
|
|
for name, err := range crewErrors {
|
|
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), false, err.Error())
|
|
allOK = false
|
|
}
|
|
}
|
|
|
|
// 7. Polecats with pinned work (if --restore)
|
|
for _, rigName := range rigs {
|
|
polecatsStarted, polecatErrors := startPolecatsWithWork(t, townRoot, rigName)
|
|
for _, name := range polecatsStarted {
|
|
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name))
|
|
}
|
|
for name, err := range polecatErrors {
|
|
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), false, err.Error())
|
|
allOK = false
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
if allOK {
|
|
fmt.Printf("%s All services running\n", style.Bold.Render("✓"))
|
|
// Log boot event with started services
|
|
startedServices := []string{"daemon", "deacon", "mayor"}
|
|
for _, rigName := range rigs {
|
|
startedServices = append(startedServices, fmt.Sprintf("%s/witness", rigName))
|
|
startedServices = append(startedServices, fmt.Sprintf("%s/refinery", rigName))
|
|
}
|
|
_ = events.LogFeed(events.TypeBoot, "gt", events.BootPayload("town", startedServices))
|
|
} else {
|
|
fmt.Printf("%s Some services failed to start\n", style.Bold.Render("✗"))
|
|
return fmt.Errorf("not all services started")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func printStatus(name string, ok bool, detail string) {
|
|
if upQuiet && ok {
|
|
return
|
|
}
|
|
if ok {
|
|
fmt.Printf("%s %s: %s\n", style.SuccessPrefix, name, style.Dim.Render(detail))
|
|
} else {
|
|
fmt.Printf("%s %s: %s\n", style.ErrorPrefix, name, detail)
|
|
}
|
|
}
|
|
|
|
// ensureDaemon starts the daemon if not running.
|
|
func ensureDaemon(townRoot string) error {
|
|
running, _, err := daemon.IsRunning(townRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if running {
|
|
return nil
|
|
}
|
|
|
|
// Start daemon
|
|
gtPath, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd := exec.Command(gtPath, "daemon", "run")
|
|
cmd.Dir = townRoot
|
|
// Detach from parent I/O for background daemon (uses its own logging)
|
|
cmd.Stdin = nil
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = nil
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Wait for daemon to initialize
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
// Verify it started
|
|
running, _, err = daemon.IsRunning(townRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !running {
|
|
return fmt.Errorf("daemon failed to start")
|
|
}
|
|
|
|
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.
|
|
func discoverRigs(townRoot string) []string {
|
|
var rigs []string
|
|
|
|
// Try rigs.json first
|
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
if rigsConfig, err := config.LoadRigsConfig(rigsConfigPath); err == nil {
|
|
for name := range rigsConfig.Rigs {
|
|
rigs = append(rigs, name)
|
|
}
|
|
return rigs
|
|
}
|
|
|
|
// Fallback: scan directory for rig-like directories
|
|
entries, err := os.ReadDir(townRoot)
|
|
if err != nil {
|
|
return rigs
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
name := entry.Name()
|
|
// Skip known non-rig directories
|
|
if name == "mayor" || name == "daemon" || name == "deacon" ||
|
|
name == ".git" || name == "docs" || name[0] == '.' {
|
|
continue
|
|
}
|
|
|
|
dirPath := filepath.Join(townRoot, name)
|
|
|
|
// Check for .beads directory (indicates a rig)
|
|
beadsPath := filepath.Join(dirPath, ".beads")
|
|
if _, err := os.Stat(beadsPath); err == nil {
|
|
rigs = append(rigs, name)
|
|
continue
|
|
}
|
|
|
|
// Check for polecats directory (indicates a rig)
|
|
polecatsPath := filepath.Join(dirPath, "polecats")
|
|
if _, err := os.Stat(polecatsPath); err == nil {
|
|
rigs = append(rigs, name)
|
|
}
|
|
}
|
|
|
|
return rigs
|
|
}
|
|
|
|
// startCrewFromSettings starts crew members based on rig settings.
|
|
// Returns list of started crew names and map of errors.
|
|
func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) {
|
|
started := []string{}
|
|
errors := map[string]error{}
|
|
|
|
rigPath := filepath.Join(townRoot, rigName)
|
|
|
|
// Load rig settings
|
|
settingsPath := filepath.Join(rigPath, "settings", "config.json")
|
|
settings, err := config.LoadRigSettings(settingsPath)
|
|
if err != nil {
|
|
// No settings file or error - skip crew startup
|
|
return started, errors
|
|
}
|
|
|
|
if settings.Crew == nil || settings.Crew.Startup == "" {
|
|
// No crew startup preference
|
|
return started, errors
|
|
}
|
|
|
|
// Get available crew members using helper
|
|
crewMgr, _, err := getCrewManager(rigName)
|
|
if err != nil {
|
|
return started, errors
|
|
}
|
|
|
|
crewWorkers, err := crewMgr.List()
|
|
if err != nil {
|
|
return started, errors
|
|
}
|
|
|
|
if len(crewWorkers) == 0 {
|
|
return started, errors
|
|
}
|
|
|
|
// Extract crew names
|
|
crewNames := make([]string, len(crewWorkers))
|
|
for i, w := range crewWorkers {
|
|
crewNames[i] = w.Name
|
|
}
|
|
|
|
// Parse startup preference and determine which crew to start
|
|
toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames)
|
|
|
|
// Start each crew member
|
|
for _, crewName := range toStart {
|
|
sessionName := fmt.Sprintf("gt-%s-crew-%s", rigName, crewName)
|
|
|
|
running, err := t.HasSession(sessionName)
|
|
if err != nil {
|
|
errors[crewName] = err
|
|
continue
|
|
}
|
|
if running {
|
|
started = append(started, crewName)
|
|
continue
|
|
}
|
|
|
|
// Start the crew member
|
|
crewPath := filepath.Join(rigPath, "crew", crewName)
|
|
if err := ensureCrewSession(t, sessionName, crewPath, rigName, crewName); err != nil {
|
|
errors[crewName] = err
|
|
} else {
|
|
started = append(started, crewName)
|
|
}
|
|
}
|
|
|
|
return started, errors
|
|
}
|
|
|
|
// parseCrewStartupPreference parses the natural language crew startup preference.
|
|
// Examples: "max", "joe and max", "all", "none", "pick one"
|
|
func parseCrewStartupPreference(pref string, available []string) []string {
|
|
pref = strings.ToLower(strings.TrimSpace(pref))
|
|
|
|
// Special keywords
|
|
switch pref {
|
|
case "none", "":
|
|
return []string{}
|
|
case "all":
|
|
return available
|
|
case "pick one", "any", "any one":
|
|
if len(available) > 0 {
|
|
return []string{available[0]}
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
// Parse comma/and-separated list
|
|
// "joe and max" -> ["joe", "max"]
|
|
// "joe, max" -> ["joe", "max"]
|
|
// "max" -> ["max"]
|
|
pref = strings.ReplaceAll(pref, " and ", ",")
|
|
pref = strings.ReplaceAll(pref, ", but not ", ",-")
|
|
pref = strings.ReplaceAll(pref, " but not ", ",-")
|
|
|
|
parts := strings.Split(pref, ",")
|
|
|
|
include := []string{}
|
|
exclude := map[string]bool{}
|
|
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(part, "-") {
|
|
// Exclusion
|
|
exclude[strings.TrimPrefix(part, "-")] = true
|
|
} else {
|
|
include = append(include, part)
|
|
}
|
|
}
|
|
|
|
// Filter to only available crew members
|
|
result := []string{}
|
|
for _, name := range include {
|
|
if exclude[name] {
|
|
continue
|
|
}
|
|
// Check if this crew exists
|
|
for _, avail := range available {
|
|
if avail == name {
|
|
result = append(result, name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
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).
|
|
// Returns list of started polecat names and map of errors.
|
|
func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) {
|
|
started := []string{}
|
|
errors := map[string]error{}
|
|
|
|
rigPath := filepath.Join(townRoot, rigName)
|
|
polecatsDir := filepath.Join(rigPath, "polecats")
|
|
|
|
// List polecat directories
|
|
entries, err := os.ReadDir(polecatsDir)
|
|
if err != nil {
|
|
// No polecats directory
|
|
return started, errors
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
polecatName := entry.Name()
|
|
polecatPath := filepath.Join(polecatsDir, polecatName)
|
|
|
|
// Check if this polecat has a pinned bead (work attached)
|
|
agentID := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
|
b := beads.New(polecatPath)
|
|
pinnedBeads, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusPinned,
|
|
Assignee: agentID,
|
|
Priority: -1,
|
|
})
|
|
if err != nil || len(pinnedBeads) == 0 {
|
|
// No pinned beads - skip
|
|
continue
|
|
}
|
|
|
|
// This polecat has work - start it
|
|
sessionName := fmt.Sprintf("gt-%s-polecat-%s", rigName, polecatName)
|
|
|
|
running, err := t.HasSession(sessionName)
|
|
if err != nil {
|
|
errors[polecatName] = err
|
|
continue
|
|
}
|
|
if running {
|
|
started = append(started, polecatName)
|
|
continue
|
|
}
|
|
|
|
// Start the polecat
|
|
if err := ensurePolecatSession(t, sessionName, polecatPath, rigName, polecatName); err != nil {
|
|
errors[polecatName] = err
|
|
} else {
|
|
started = append(started, polecatName)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|