Merge main with statusline fixes
This commit is contained in:
328
internal/cmd/agents.go
Normal file
328
internal/cmd/agents.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
// AgentType represents the type of Gas Town agent.
|
||||
type AgentType int
|
||||
|
||||
const (
|
||||
AgentMayor AgentType = iota
|
||||
AgentDeacon
|
||||
AgentWitness
|
||||
AgentRefinery
|
||||
AgentCrew
|
||||
AgentPolecat
|
||||
)
|
||||
|
||||
// AgentSession represents a categorized tmux session.
|
||||
type AgentSession struct {
|
||||
Name string
|
||||
Type AgentType
|
||||
Rig string // For rig-specific agents
|
||||
AgentName string // e.g., crew name, polecat name
|
||||
}
|
||||
|
||||
// AgentTypeColors maps agent types to tmux color codes.
|
||||
var AgentTypeColors = map[AgentType]string{
|
||||
AgentMayor: "#[fg=red,bold]",
|
||||
AgentDeacon: "#[fg=yellow,bold]",
|
||||
AgentWitness: "#[fg=cyan]",
|
||||
AgentRefinery: "#[fg=blue]",
|
||||
AgentCrew: "#[fg=green]",
|
||||
AgentPolecat: "#[fg=white,dim]",
|
||||
}
|
||||
|
||||
// AgentTypeIcons maps agent types to display icons.
|
||||
var AgentTypeIcons = map[AgentType]string{
|
||||
AgentMayor: "👑",
|
||||
AgentDeacon: "⛪",
|
||||
AgentWitness: "👁",
|
||||
AgentRefinery: "🏭",
|
||||
AgentCrew: "👷",
|
||||
AgentPolecat: "🐾",
|
||||
}
|
||||
|
||||
var agentsCmd = &cobra.Command{
|
||||
Use: "agents",
|
||||
Aliases: []string{"ag"},
|
||||
Short: "Switch between Gas Town agent sessions",
|
||||
Long: `Display a popup menu of core Gas Town agent sessions.
|
||||
|
||||
Shows Mayor, Deacon, Witnesses, Refineries, and Crew workers.
|
||||
Polecats are hidden (use 'gt polecats' to see them).
|
||||
|
||||
The menu appears as a tmux popup for quick session switching.`,
|
||||
RunE: runAgents,
|
||||
}
|
||||
|
||||
var agentsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List agent sessions (no popup)",
|
||||
Long: `List all agent sessions to stdout without the popup menu.`,
|
||||
RunE: runAgentsList,
|
||||
}
|
||||
|
||||
var agentsAllFlag bool
|
||||
|
||||
func init() {
|
||||
agentsCmd.PersistentFlags().BoolVarP(&agentsAllFlag, "all", "a", false, "Include polecats in the menu")
|
||||
agentsCmd.AddCommand(agentsListCmd)
|
||||
rootCmd.AddCommand(agentsCmd)
|
||||
}
|
||||
|
||||
// categorizeSession determines the agent type from a session name.
|
||||
func categorizeSession(name string) *AgentSession {
|
||||
// Must start with gt- prefix
|
||||
if !strings.HasPrefix(name, "gt-") {
|
||||
return nil
|
||||
}
|
||||
|
||||
session := &AgentSession{Name: name}
|
||||
suffix := strings.TrimPrefix(name, "gt-")
|
||||
|
||||
// Town-level agents
|
||||
if suffix == "mayor" {
|
||||
session.Type = AgentMayor
|
||||
return session
|
||||
}
|
||||
if suffix == "deacon" {
|
||||
session.Type = AgentDeacon
|
||||
return session
|
||||
}
|
||||
|
||||
// Rig-level agents: gt-<rig>-<type> or gt-<rig>-crew-<name>
|
||||
parts := strings.SplitN(suffix, "-", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil // Invalid format
|
||||
}
|
||||
|
||||
session.Rig = parts[0]
|
||||
remainder := parts[1]
|
||||
|
||||
// Check for crew: gt-<rig>-crew-<name>
|
||||
if strings.HasPrefix(remainder, "crew-") {
|
||||
session.Type = AgentCrew
|
||||
session.AgentName = strings.TrimPrefix(remainder, "crew-")
|
||||
return session
|
||||
}
|
||||
|
||||
// Check for other agent types
|
||||
switch remainder {
|
||||
case "witness":
|
||||
session.Type = AgentWitness
|
||||
return session
|
||||
case "refinery":
|
||||
session.Type = AgentRefinery
|
||||
return session
|
||||
}
|
||||
|
||||
// Everything else is a polecat
|
||||
session.Type = AgentPolecat
|
||||
session.AgentName = remainder
|
||||
return session
|
||||
}
|
||||
|
||||
// getAgentSessions returns all categorized Gas Town sessions.
|
||||
func getAgentSessions(includePolecats bool) ([]*AgentSession, error) {
|
||||
t := tmux.NewTmux()
|
||||
sessions, err := t.ListSessions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var agents []*AgentSession
|
||||
for _, name := range sessions {
|
||||
agent := categorizeSession(name)
|
||||
if agent == nil {
|
||||
continue
|
||||
}
|
||||
if agent.Type == AgentPolecat && !includePolecats {
|
||||
continue
|
||||
}
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
|
||||
// Sort: mayor, deacon first, then by rig, then by type
|
||||
sort.Slice(agents, func(i, j int) bool {
|
||||
a, b := agents[i], agents[j]
|
||||
|
||||
// Town-level agents first
|
||||
if a.Type == AgentMayor {
|
||||
return true
|
||||
}
|
||||
if b.Type == AgentMayor {
|
||||
return false
|
||||
}
|
||||
if a.Type == AgentDeacon {
|
||||
return true
|
||||
}
|
||||
if b.Type == AgentDeacon {
|
||||
return false
|
||||
}
|
||||
|
||||
// Then by rig name
|
||||
if a.Rig != b.Rig {
|
||||
return a.Rig < b.Rig
|
||||
}
|
||||
|
||||
// Within rig: refinery, witness, crew, polecat
|
||||
typeOrder := map[AgentType]int{
|
||||
AgentRefinery: 0,
|
||||
AgentWitness: 1,
|
||||
AgentCrew: 2,
|
||||
AgentPolecat: 3,
|
||||
}
|
||||
if typeOrder[a.Type] != typeOrder[b.Type] {
|
||||
return typeOrder[a.Type] < typeOrder[b.Type]
|
||||
}
|
||||
|
||||
// Same type: alphabetical by agent name
|
||||
return a.AgentName < b.AgentName
|
||||
})
|
||||
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
// displayLabel returns the menu display label for an agent.
|
||||
func (a *AgentSession) displayLabel() string {
|
||||
color := AgentTypeColors[a.Type]
|
||||
icon := AgentTypeIcons[a.Type]
|
||||
|
||||
switch a.Type {
|
||||
case AgentMayor:
|
||||
return fmt.Sprintf("%s%s Mayor#[default]", color, icon)
|
||||
case AgentDeacon:
|
||||
return fmt.Sprintf("%s%s Deacon#[default]", color, icon)
|
||||
case AgentWitness:
|
||||
return fmt.Sprintf("%s%s %s/witness#[default]", color, icon, a.Rig)
|
||||
case AgentRefinery:
|
||||
return fmt.Sprintf("%s%s %s/refinery#[default]", color, icon, a.Rig)
|
||||
case AgentCrew:
|
||||
return fmt.Sprintf("%s%s %s/crew/%s#[default]", color, icon, a.Rig, a.AgentName)
|
||||
case AgentPolecat:
|
||||
return fmt.Sprintf("%s%s %s/%s#[default]", color, icon, a.Rig, a.AgentName)
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// shortcutKey returns a keyboard shortcut for the menu item.
|
||||
func shortcutKey(index int) string {
|
||||
if index < 9 {
|
||||
return fmt.Sprintf("%d", index+1)
|
||||
}
|
||||
if index < 35 {
|
||||
// a-z after 1-9
|
||||
return string(rune('a' + index - 9))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runAgents(cmd *cobra.Command, args []string) error {
|
||||
agents, err := getAgentSessions(agentsAllFlag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing sessions: %w", err)
|
||||
}
|
||||
|
||||
if len(agents) == 0 {
|
||||
fmt.Println("No agent sessions running.")
|
||||
fmt.Println("\nStart agents with:")
|
||||
fmt.Println(" gt mayor start")
|
||||
fmt.Println(" gt deacon start")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build display-menu arguments
|
||||
menuArgs := []string{
|
||||
"display-menu",
|
||||
"-T", "#[fg=cyan,bold]⚙️ Gas Town Agents",
|
||||
"-x", "C", // Center horizontally
|
||||
"-y", "C", // Center vertically
|
||||
}
|
||||
|
||||
var currentRig string
|
||||
keyIndex := 0
|
||||
|
||||
for _, agent := range agents {
|
||||
// Add rig header when rig changes (skip for town-level agents)
|
||||
if agent.Rig != "" && agent.Rig != currentRig {
|
||||
if currentRig != "" || keyIndex > 0 {
|
||||
// Add separator before new rig section
|
||||
menuArgs = append(menuArgs, "")
|
||||
}
|
||||
// Add rig header (non-selectable)
|
||||
menuArgs = append(menuArgs, fmt.Sprintf("#[fg=white,dim]── %s ──", agent.Rig), "", "")
|
||||
currentRig = agent.Rig
|
||||
}
|
||||
|
||||
key := shortcutKey(keyIndex)
|
||||
label := agent.displayLabel()
|
||||
action := fmt.Sprintf("switch-client -t '%s'", agent.Name)
|
||||
|
||||
menuArgs = append(menuArgs, label, key, action)
|
||||
keyIndex++
|
||||
}
|
||||
|
||||
// Execute tmux display-menu
|
||||
tmuxPath, err := exec.LookPath("tmux")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux not found: %w", err)
|
||||
}
|
||||
|
||||
execCmd := exec.Command(tmuxPath, menuArgs...)
|
||||
execCmd.Stdin = os.Stdin
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
|
||||
return execCmd.Run()
|
||||
}
|
||||
|
||||
func runAgentsList(cmd *cobra.Command, args []string) error {
|
||||
agents, err := getAgentSessions(agentsAllFlag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing sessions: %w", err)
|
||||
}
|
||||
|
||||
if len(agents) == 0 {
|
||||
fmt.Println("No agent sessions running.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentRig string
|
||||
for _, agent := range agents {
|
||||
// Print rig header
|
||||
if agent.Rig != "" && agent.Rig != currentRig {
|
||||
if currentRig != "" {
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf("── %s ──\n", agent.Rig)
|
||||
currentRig = agent.Rig
|
||||
}
|
||||
|
||||
icon := AgentTypeIcons[agent.Type]
|
||||
switch agent.Type {
|
||||
case AgentMayor:
|
||||
fmt.Printf(" %s Mayor\n", icon)
|
||||
case AgentDeacon:
|
||||
fmt.Printf(" %s Deacon\n", icon)
|
||||
case AgentWitness:
|
||||
fmt.Printf(" %s witness\n", icon)
|
||||
case AgentRefinery:
|
||||
fmt.Printf(" %s refinery\n", icon)
|
||||
case AgentCrew:
|
||||
fmt.Printf(" %s crew/%s\n", icon, agent.AgentName)
|
||||
case AgentPolecat:
|
||||
fmt.Printf(" %s %s\n", icon, agent.AgentName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
@@ -73,12 +75,27 @@ Stops the current session (if running) and starts a fresh one.`,
|
||||
RunE: runDeaconRestart,
|
||||
}
|
||||
|
||||
var deaconHeartbeatCmd = &cobra.Command{
|
||||
Use: "heartbeat [action]",
|
||||
Short: "Update the Deacon heartbeat",
|
||||
Long: `Update the Deacon heartbeat file.
|
||||
|
||||
The heartbeat signals to the daemon that the Deacon is alive and working.
|
||||
Call this at the start of each wake cycle to prevent daemon pokes.
|
||||
|
||||
Examples:
|
||||
gt deacon heartbeat # Touch heartbeat with timestamp
|
||||
gt deacon heartbeat "checking mayor" # Touch with action description`,
|
||||
RunE: runDeaconHeartbeat,
|
||||
}
|
||||
|
||||
func init() {
|
||||
deaconCmd.AddCommand(deaconStartCmd)
|
||||
deaconCmd.AddCommand(deaconStopCmd)
|
||||
deaconCmd.AddCommand(deaconAttachCmd)
|
||||
deaconCmd.AddCommand(deaconStatusCmd)
|
||||
deaconCmd.AddCommand(deaconRestartCmd)
|
||||
deaconCmd.AddCommand(deaconHeartbeatCmd)
|
||||
|
||||
rootCmd.AddCommand(deaconCmd)
|
||||
}
|
||||
@@ -123,6 +140,10 @@ func startDeaconSession(t *tmux.Tmux) error {
|
||||
// Set environment
|
||||
_ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
||||
|
||||
// Apply Deacon theme
|
||||
theme := tmux.DeaconTheme()
|
||||
_ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check")
|
||||
|
||||
// Launch Claude in a respawn loop - session survives restarts
|
||||
// The startup hook handles context loading automatically
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
@@ -243,3 +264,29 @@ func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
||||
// Not running, start fresh
|
||||
return runDeaconStart(cmd, args)
|
||||
}
|
||||
|
||||
func runDeaconHeartbeat(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
action := ""
|
||||
if len(args) > 0 {
|
||||
action = strings.Join(args, " ")
|
||||
}
|
||||
|
||||
if action != "" {
|
||||
if err := deacon.TouchWithAction(townRoot, action, 0, 0); err != nil {
|
||||
return fmt.Errorf("updating heartbeat: %w", err)
|
||||
}
|
||||
fmt.Printf("%s Heartbeat updated: %s\n", style.Bold.Render("✓"), action)
|
||||
} else {
|
||||
if err := deacon.Touch(townRoot); err != nil {
|
||||
return fmt.Errorf("updating heartbeat: %w", err)
|
||||
}
|
||||
fmt.Printf("%s Heartbeat updated\n", style.Bold.Render("✓"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d := doctor.NewDoctor()
|
||||
|
||||
// Register built-in checks
|
||||
d.Register(doctor.NewDaemonCheck())
|
||||
d.Register(doctor.NewBeadsDatabaseCheck())
|
||||
|
||||
// Run checks
|
||||
|
||||
139
internal/cmd/down.go
Normal file
139
internal/cmd/down.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/daemon"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
var downCmd = &cobra.Command{
|
||||
Use: "down",
|
||||
Short: "Stop all Gas Town services",
|
||||
Long: `Stop all Gas Town long-lived services.
|
||||
|
||||
This gracefully shuts down all infrastructure agents:
|
||||
|
||||
• Witnesses - Per-rig polecat managers
|
||||
• Mayor - Global work coordinator
|
||||
• Deacon - Health orchestrator
|
||||
• Daemon - Go background process
|
||||
|
||||
Polecats are NOT stopped by this command - use 'gt swarm stop' or
|
||||
kill individual polecats with 'gt polecat kill'.
|
||||
|
||||
This is useful for:
|
||||
• Taking a break (stop token consumption)
|
||||
• Clean shutdown before system maintenance
|
||||
• Resetting the town to a clean state`,
|
||||
RunE: runDown,
|
||||
}
|
||||
|
||||
var (
|
||||
downQuiet bool
|
||||
downForce bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
downCmd.Flags().BoolVarP(&downQuiet, "quiet", "q", false, "Only show errors")
|
||||
downCmd.Flags().BoolVarP(&downForce, "force", "f", false, "Force kill without graceful shutdown")
|
||||
rootCmd.AddCommand(downCmd)
|
||||
}
|
||||
|
||||
func runDown(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
|
||||
|
||||
// Stop in reverse order of startup
|
||||
|
||||
// 1. Stop witnesses first
|
||||
rigs := discoverRigs(townRoot)
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
||||
if err := stopSession(t, sessionName); err != nil {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stop Mayor
|
||||
if err := stopSession(t, MayorSessionName); err != nil {
|
||||
printDownStatus("Mayor", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Mayor", true, "stopped")
|
||||
}
|
||||
|
||||
// 3. Stop Deacon
|
||||
if err := stopSession(t, DeaconSessionName); err != nil {
|
||||
printDownStatus("Deacon", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Deacon", true, "stopped")
|
||||
}
|
||||
|
||||
// 4. Stop Daemon last
|
||||
running, _, _ := daemon.IsRunning(townRoot)
|
||||
if running {
|
||||
if err := daemon.StopDaemon(townRoot); err != nil {
|
||||
printDownStatus("Daemon", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printDownStatus("Daemon", true, "stopped")
|
||||
}
|
||||
} else {
|
||||
printDownStatus("Daemon", true, "not running")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if allOK {
|
||||
fmt.Printf("%s All services stopped\n", style.Bold.Render("✓"))
|
||||
} else {
|
||||
fmt.Printf("%s Some services failed to stop\n", style.Bold.Render("✗"))
|
||||
return fmt.Errorf("not all services stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDownStatus(name string, ok bool, detail string) {
|
||||
if downQuiet && 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)
|
||||
}
|
||||
}
|
||||
|
||||
// stopSession gracefully stops a tmux session.
|
||||
func stopSession(t *tmux.Tmux, sessionName string) error {
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !running {
|
||||
return nil // Already stopped
|
||||
}
|
||||
|
||||
// Try graceful shutdown first (Ctrl-C)
|
||||
if !downForce {
|
||||
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
return t.KillSession(sessionName)
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
var (
|
||||
mailSubject string
|
||||
mailBody string
|
||||
mailPriority string
|
||||
mailPriority int
|
||||
mailUrgent bool
|
||||
mailType string
|
||||
mailReplyTo string
|
||||
mailNotify bool
|
||||
@@ -61,14 +62,21 @@ Message types:
|
||||
notification - Informational (default)
|
||||
reply - Response to message
|
||||
|
||||
Priority levels:
|
||||
low, normal (default), high, urgent
|
||||
Priority levels (compatible with bd mail send):
|
||||
0 - urgent/critical
|
||||
1 - high
|
||||
2 - normal (default)
|
||||
3 - low
|
||||
4 - backlog
|
||||
|
||||
Use --urgent as shortcut for --priority 0.
|
||||
|
||||
Examples:
|
||||
gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?"
|
||||
gt mail send mayor/ -s "Work complete" -m "Finished gt-abc"
|
||||
gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify
|
||||
gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority high
|
||||
gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority 1
|
||||
gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent
|
||||
gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailSend,
|
||||
@@ -167,7 +175,8 @@ func init() {
|
||||
// Send flags
|
||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
|
||||
mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (low, normal, high, urgent)")
|
||||
mailSendCmd.Flags().IntVar(&mailPriority, "priority", 2, "Message priority (0=urgent, 1=high, 2=normal, 3=low, 4=backlog)")
|
||||
mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)")
|
||||
mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)")
|
||||
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
|
||||
mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
|
||||
@@ -228,8 +237,12 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
Body: mailBody,
|
||||
}
|
||||
|
||||
// Set priority
|
||||
msg.Priority = mail.ParsePriority(mailPriority)
|
||||
// Set priority (--urgent overrides --priority)
|
||||
if mailUrgent {
|
||||
msg.Priority = mail.PriorityUrgent
|
||||
} else {
|
||||
msg.Priority = mail.PriorityFromInt(mailPriority)
|
||||
}
|
||||
if mailNotify && msg.Priority == mail.PriorityNormal {
|
||||
msg.Priority = mail.PriorityHigh
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
@@ -169,9 +170,29 @@ Examples:
|
||||
RunE: runPolecatSync,
|
||||
}
|
||||
|
||||
var polecatStatusCmd = &cobra.Command{
|
||||
Use: "status <rig>/<polecat>",
|
||||
Short: "Show detailed status for a polecat",
|
||||
Long: `Show detailed status for a polecat.
|
||||
|
||||
Displays comprehensive information including:
|
||||
- Current lifecycle state (working, done, stuck, idle)
|
||||
- Assigned issue (if any)
|
||||
- Session status (running/stopped, attached/detached)
|
||||
- Session creation time
|
||||
- Last activity time
|
||||
|
||||
Examples:
|
||||
gt polecat status gastown/Toast
|
||||
gt polecat status gastown/Toast --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPolecatStatus,
|
||||
}
|
||||
|
||||
var (
|
||||
polecatSyncAll bool
|
||||
polecatSyncFromMain bool
|
||||
polecatStatusJSON bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -186,6 +207,9 @@ func init() {
|
||||
polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig")
|
||||
polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push")
|
||||
|
||||
// Status flags
|
||||
polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Add subcommands
|
||||
polecatCmd.AddCommand(polecatListCmd)
|
||||
polecatCmd.AddCommand(polecatAddCmd)
|
||||
@@ -195,6 +219,7 @@ func init() {
|
||||
polecatCmd.AddCommand(polecatDoneCmd)
|
||||
polecatCmd.AddCommand(polecatResetCmd)
|
||||
polecatCmd.AddCommand(polecatSyncCmd)
|
||||
polecatCmd.AddCommand(polecatStatusCmd)
|
||||
|
||||
rootCmd.AddCommand(polecatCmd)
|
||||
}
|
||||
@@ -577,3 +602,152 @@ func runPolecatSync(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PolecatStatus represents detailed polecat status for JSON output.
|
||||
type PolecatStatus struct {
|
||||
Rig string `json:"rig"`
|
||||
Name string `json:"name"`
|
||||
State polecat.State `json:"state"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
ClonePath string `json:"clone_path"`
|
||||
Branch string `json:"branch"`
|
||||
SessionRunning bool `json:"session_running"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Attached bool `json:"attached,omitempty"`
|
||||
Windows int `json:"windows,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
LastActivity string `json:"last_activity,omitempty"`
|
||||
}
|
||||
|
||||
func runPolecatStatus(cmd *cobra.Command, args []string) error {
|
||||
rigName, polecatName, err := parseAddress(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mgr, r, err := getPolecatManager(rigName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get polecat info
|
||||
p, err := mgr.Get(polecatName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
|
||||
}
|
||||
|
||||
// Get session info
|
||||
t := tmux.NewTmux()
|
||||
sessMgr := session.NewManager(t, r)
|
||||
sessInfo, err := sessMgr.Status(polecatName)
|
||||
if err != nil {
|
||||
// Non-fatal - continue without session info
|
||||
sessInfo = &session.Info{
|
||||
Polecat: polecatName,
|
||||
Running: false,
|
||||
}
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if polecatStatusJSON {
|
||||
status := PolecatStatus{
|
||||
Rig: rigName,
|
||||
Name: polecatName,
|
||||
State: p.State,
|
||||
Issue: p.Issue,
|
||||
ClonePath: p.ClonePath,
|
||||
Branch: p.Branch,
|
||||
SessionRunning: sessInfo.Running,
|
||||
SessionID: sessInfo.SessionID,
|
||||
Attached: sessInfo.Attached,
|
||||
Windows: sessInfo.Windows,
|
||||
}
|
||||
if !sessInfo.Created.IsZero() {
|
||||
status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if !sessInfo.LastActivity.IsZero() {
|
||||
status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(status)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName)))
|
||||
|
||||
// State with color
|
||||
stateStr := string(p.State)
|
||||
switch p.State {
|
||||
case polecat.StateWorking:
|
||||
stateStr = style.Info.Render(stateStr)
|
||||
case polecat.StateStuck:
|
||||
stateStr = style.Warning.Render(stateStr)
|
||||
case polecat.StateDone:
|
||||
stateStr = style.Success.Render(stateStr)
|
||||
default:
|
||||
stateStr = style.Dim.Render(stateStr)
|
||||
}
|
||||
fmt.Printf(" State: %s\n", stateStr)
|
||||
|
||||
// Issue
|
||||
if p.Issue != "" {
|
||||
fmt.Printf(" Issue: %s\n", p.Issue)
|
||||
} else {
|
||||
fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)"))
|
||||
}
|
||||
|
||||
// Clone path and branch
|
||||
fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath))
|
||||
fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch))
|
||||
|
||||
// Session info
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Bold.Render("Session"))
|
||||
|
||||
if sessInfo.Running {
|
||||
fmt.Printf(" Status: %s\n", style.Success.Render("running"))
|
||||
fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID))
|
||||
|
||||
if sessInfo.Attached {
|
||||
fmt.Printf(" Attached: %s\n", style.Info.Render("yes"))
|
||||
} else {
|
||||
fmt.Printf(" Attached: %s\n", style.Dim.Render("no"))
|
||||
}
|
||||
|
||||
if sessInfo.Windows > 0 {
|
||||
fmt.Printf(" Windows: %d\n", sessInfo.Windows)
|
||||
}
|
||||
|
||||
if !sessInfo.Created.IsZero() {
|
||||
fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
if !sessInfo.LastActivity.IsZero() {
|
||||
// Show relative time for activity
|
||||
ago := formatActivityTime(sessInfo.LastActivity)
|
||||
fmt.Printf(" Last Activity: %s (%s)\n",
|
||||
sessInfo.LastActivity.Format("15:04:05"),
|
||||
style.Dim.Render(ago))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" Status: %s\n", style.Dim.Render("not running"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatActivityTime returns a human-readable relative time string.
|
||||
func formatActivityTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%d hours ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,16 @@ func outputStartupDirective(ctx RoleContext) {
|
||||
fmt.Println("2. Check mail: `gt mail inbox`")
|
||||
fmt.Println("3. If there's a 🤝 HANDOFF message, read it and summarize")
|
||||
fmt.Println("4. If no mail, await user instruction")
|
||||
case RoleWitness:
|
||||
fmt.Println()
|
||||
fmt.Println("---")
|
||||
fmt.Println()
|
||||
fmt.Println("**STARTUP PROTOCOL**: You are the Witness. Please:")
|
||||
fmt.Println("1. Check for handoff: `gt mail inbox` - look for 🤝 HANDOFF messages")
|
||||
fmt.Println("2. Check polecat status: `gt polecat list " + ctx.Rig + " --json`")
|
||||
fmt.Println("3. Process any lifecycle requests from inbox")
|
||||
fmt.Println("4. If polecats stuck/idle, nudge them")
|
||||
fmt.Println("5. If all quiet, wait for activity")
|
||||
case RolePolecat:
|
||||
fmt.Println()
|
||||
fmt.Println("---")
|
||||
@@ -409,6 +419,15 @@ func outputStartupDirective(ctx RoleContext) {
|
||||
fmt.Println("1. Check mail: `gt mail inbox`")
|
||||
fmt.Println("2. If assigned work, begin immediately")
|
||||
fmt.Println("3. If no work, announce ready and await assignment")
|
||||
case RoleRefinery:
|
||||
fmt.Println()
|
||||
fmt.Println("---")
|
||||
fmt.Println()
|
||||
fmt.Println("**STARTUP PROTOCOL**: You are the Refinery. Please:")
|
||||
fmt.Println("1. Check mail: `gt mail inbox`")
|
||||
fmt.Printf("2. Check merge queue: `gt refinery queue %s`\n", ctx.Rig)
|
||||
fmt.Println("3. If MRs pending, process them one at a time")
|
||||
fmt.Println("4. If no work, monitor for new MRs periodically")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/refinery"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
@@ -77,6 +78,20 @@ Lists all pending merge requests waiting to be processed.`,
|
||||
RunE: runRefineryQueue,
|
||||
}
|
||||
|
||||
var refineryAttachCmd = &cobra.Command{
|
||||
Use: "attach <rig>",
|
||||
Short: "Attach to refinery session",
|
||||
Long: `Attach to a running Refinery's Claude session.
|
||||
|
||||
Allows interactive access to the Refinery agent for debugging
|
||||
or manual intervention.
|
||||
|
||||
Examples:
|
||||
gt refinery attach gastown`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRefineryAttach,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Start flags
|
||||
refineryStartCmd.Flags().BoolVar(&refineryForeground, "foreground", false, "Run in foreground (default: background)")
|
||||
@@ -92,6 +107,7 @@ func init() {
|
||||
refineryCmd.AddCommand(refineryStopCmd)
|
||||
refineryCmd.AddCommand(refineryStatusCmd)
|
||||
refineryCmd.AddCommand(refineryQueueCmd)
|
||||
refineryCmd.AddCommand(refineryAttachCmd)
|
||||
|
||||
rootCmd.AddCommand(refineryCmd)
|
||||
}
|
||||
@@ -315,3 +331,41 @@ func runRefineryQueue(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRefineryAttach(cmd *cobra.Command, args []string) error {
|
||||
rigName := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Session name follows the same pattern as refinery manager
|
||||
sessionID := fmt.Sprintf("gt-%s-refinery", rigName)
|
||||
|
||||
// Check if session exists
|
||||
t := tmux.NewTmux()
|
||||
running, err := t.HasSession(sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return fmt.Errorf("refinery is not running for rig '%s'", rigName)
|
||||
}
|
||||
|
||||
// Verify rig exists
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
}
|
||||
|
||||
g := git.NewGit(townRoot)
|
||||
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
|
||||
if _, err := rigMgr.GetRig(rigName); err != nil {
|
||||
return fmt.Errorf("rig '%s' not found", rigName)
|
||||
}
|
||||
|
||||
// Attach to the session
|
||||
return t.AttachSession(sessionID)
|
||||
}
|
||||
|
||||
@@ -93,17 +93,17 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
return nil // Silent fail
|
||||
}
|
||||
|
||||
// Count gt-* sessions (polecats) and rigs
|
||||
// Count only actual polecats (not witnesses, refineries, deacon, crew)
|
||||
polecatCount := 0
|
||||
rigs := make(map[string]bool)
|
||||
for _, s := range sessions {
|
||||
if strings.HasPrefix(s, "gt-") && s != "gt-mayor" {
|
||||
agent := categorizeSession(s)
|
||||
if agent == nil {
|
||||
continue
|
||||
}
|
||||
if agent.Type == AgentPolecat {
|
||||
polecatCount++
|
||||
// Extract rig name: gt-<rig>-<worker>
|
||||
parts := strings.SplitN(s, "-", 3)
|
||||
if len(parts) >= 2 {
|
||||
rigs[parts[1]] = true
|
||||
}
|
||||
rigs[agent.Rig] = true
|
||||
}
|
||||
}
|
||||
rigCount := len(rigs)
|
||||
|
||||
75
internal/cmd/statusline_test.go
Normal file
75
internal/cmd/statusline_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractRigFromSession(t *testing.T) {
|
||||
tests := []struct {
|
||||
session string
|
||||
want string
|
||||
}{
|
||||
// Standard polecat sessions
|
||||
{"gt-gastown-slit", "gastown"},
|
||||
{"gt-gastown-Toast", "gastown"},
|
||||
{"gt-myrig-worker", "myrig"},
|
||||
|
||||
// Crew sessions
|
||||
{"gt-gastown-crew-max", "gastown"},
|
||||
{"gt-myrig-crew-user", "myrig"},
|
||||
|
||||
// Witness sessions (daemon.go style: gt-<rig>-witness)
|
||||
{"gt-gastown-witness", "gastown"},
|
||||
{"gt-myrig-witness", "myrig"},
|
||||
|
||||
// Witness sessions (witness.go style: gt-witness-<rig>)
|
||||
{"gt-witness-gastown", "gastown"},
|
||||
{"gt-witness-myrig", "myrig"},
|
||||
|
||||
// Refinery sessions
|
||||
{"gt-gastown-refinery", "gastown"},
|
||||
{"gt-myrig-refinery", "myrig"},
|
||||
|
||||
// Edge cases
|
||||
{"gt-a-b", "a"}, // minimum valid
|
||||
{"gt-ab", ""}, // too short, no worker
|
||||
{"gt-", ""}, // invalid
|
||||
{"gt", ""}, // invalid
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.session, func(t *testing.T) {
|
||||
got := extractRigFromSession(tt.session)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractRigFromSession(%q) = %q, want %q", tt.session, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPolecatSession(t *testing.T) {
|
||||
tests := []struct {
|
||||
session string
|
||||
want bool
|
||||
}{
|
||||
// Polecat sessions (should return true)
|
||||
{"gt-gastown-slit", true},
|
||||
{"gt-gastown-Toast", true},
|
||||
{"gt-myrig-worker", true},
|
||||
{"gt-a-b", true},
|
||||
|
||||
// Non-polecat sessions (should return false)
|
||||
{"gt-gastown-witness", false},
|
||||
{"gt-witness-gastown", false},
|
||||
{"gt-gastown-refinery", false},
|
||||
{"gt-gastown-crew-max", false},
|
||||
{"gt-myrig-crew-user", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.session, func(t *testing.T) {
|
||||
got := isPolecatSession(tt.session)
|
||||
if got != tt.want {
|
||||
t.Errorf("isPolecatSession(%q) = %v, want %v", tt.session, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
285
internal/cmd/up.go
Normal file
285
internal/cmd/up.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/daemon"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
var upCmd = &cobra.Command{
|
||||
Use: "up",
|
||||
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
|
||||
|
||||
Polecats are NOT started by this command - they are ephemeral workers
|
||||
spawned on demand by the Mayor or Witnesses.
|
||||
|
||||
Running 'gt up' multiple times is safe - it only starts services that
|
||||
aren't already running.`,
|
||||
RunE: runUp,
|
||||
}
|
||||
|
||||
var (
|
||||
upQuiet bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors")
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Deacon (Claude agent)
|
||||
if err := ensureSession(t, DeaconSessionName, townRoot, "deacon"); err != nil {
|
||||
printStatus("Deacon", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printStatus("Deacon", true, "gt-deacon")
|
||||
}
|
||||
|
||||
// 3. Mayor (Claude agent)
|
||||
if err := ensureSession(t, MayorSessionName, townRoot, "mayor"); err != nil {
|
||||
printStatus("Mayor", false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printStatus("Mayor", true, "gt-mayor")
|
||||
}
|
||||
|
||||
// 4. Witnesses (one per rig)
|
||||
rigs := discoverRigs(townRoot)
|
||||
for _, rigName := range rigs {
|
||||
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
||||
rigPath := filepath.Join(townRoot, rigName)
|
||||
|
||||
if err := ensureWitness(t, sessionName, rigPath, rigName); err != nil {
|
||||
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
|
||||
allOK = false
|
||||
} else {
|
||||
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if allOK {
|
||||
fmt.Printf("%s All services running\n", style.Bold.Render("✓"))
|
||||
} 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
|
||||
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
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := t.NewSession(sessionName, workDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set environment
|
||||
_ = t.SetEnvironment(sessionName, "GT_ROLE", role)
|
||||
|
||||
// Apply theme based on role
|
||||
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
|
||||
var claudeCmd string
|
||||
if role == "deacon" {
|
||||
// Deacon uses respawn loop
|
||||
claudeCmd = `while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
||||
} else {
|
||||
claudeCmd = `claude --dangerously-skip-permissions`
|
||||
}
|
||||
|
||||
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureWitness starts a witness session for a rig.
|
||||
func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error {
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create session in rig directory
|
||||
if err := t.NewSession(sessionName, rigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set environment
|
||||
_ = t.SetEnvironment(sessionName, "GT_ROLE", "witness")
|
||||
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
|
||||
|
||||
// Apply theme (use rig-based theme)
|
||||
theme := tmux.AssignTheme(rigName)
|
||||
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName)
|
||||
|
||||
// Launch Claude
|
||||
claudeCmd := `claude --dangerously-skip-permissions`
|
||||
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -254,11 +254,17 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
|
||||
// Witness working directory - use <rig>/witness/ for proper role detection
|
||||
witnessDir := filepath.Join(r.Path, "witness")
|
||||
if err := os.MkdirAll(witnessDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating witness directory: %w", err)
|
||||
}
|
||||
|
||||
if !running {
|
||||
// Start witness session (like Mayor)
|
||||
fmt.Printf("Starting witness session for %s...\n", rigName)
|
||||
|
||||
if err := t.NewSession(sessionName, r.Path); err != nil {
|
||||
if err := t.NewSession(sessionName, witnessDir); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user