Merge main with statusline fixes

This commit is contained in:
Steve Yegge
2025-12-20 08:19:00 -08:00
34 changed files with 2702 additions and 920 deletions

328
internal/cmd/agents.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -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
View 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)
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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)

View 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
View 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
}

View File

@@ -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)
}