GUPP (Gas Town Universal Propulsion Principle) is the propulsion nudge sent after beacon to trigger autonomous work execution. Previously only polecats received this nudge. Now all roles get role-specific propulsion nudges on startup: - Polecat/Crew: "Run `gt hook` to check your hook and begin work." - Witness: "Run `gt prime` to check patrol status and begin work." - Refinery: "Run `gt prime` to check MQ status and begin patrol." - Deacon: "Run `gt prime` to check patrol status and begin heartbeat cycle." - Mayor: "Run `gt prime` to check mail and begin coordination." Changes: - internal/session/names.go: Add PropulsionNudgeForRole() function - internal/cmd/witness.go: Add GUPP nudge to ensureWitnessSession - internal/cmd/start.go: Add GUPP nudge to ensureRefinerySession (also converted from respawn loop to direct Claude launch like other roles) - internal/cmd/deacon.go: Add GUPP nudge to startDeaconSession - internal/cmd/mayor.go: Add GUPP nudge to startMayorSession Fixes: gt-zzpmt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
270 lines
7.6 KiB
Go
270 lines
7.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// MayorSessionName is the tmux session name for the Mayor.
|
|
const MayorSessionName = "gt-mayor"
|
|
|
|
var mayorCmd = &cobra.Command{
|
|
Use: "mayor",
|
|
Aliases: []string{"may"},
|
|
GroupID: GroupAgents,
|
|
Short: "Manage the Mayor session",
|
|
RunE: requireSubcommand,
|
|
Long: `Manage the Mayor tmux session.
|
|
|
|
The Mayor is the global coordinator for Gas Town, running as a persistent
|
|
tmux session. Use the subcommands to start, stop, attach, and check status.`,
|
|
}
|
|
|
|
var mayorStartCmd = &cobra.Command{
|
|
Use: "start",
|
|
Short: "Start the Mayor session",
|
|
Long: `Start the Mayor tmux session.
|
|
|
|
Creates a new detached tmux session for the Mayor and launches Claude.
|
|
The session runs in the workspace root directory.`,
|
|
RunE: runMayorStart,
|
|
}
|
|
|
|
var mayorStopCmd = &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the Mayor session",
|
|
Long: `Stop the Mayor tmux session.
|
|
|
|
Attempts graceful shutdown first (Ctrl-C), then kills the tmux session.`,
|
|
RunE: runMayorStop,
|
|
}
|
|
|
|
var mayorAttachCmd = &cobra.Command{
|
|
Use: "attach",
|
|
Aliases: []string{"at"},
|
|
Short: "Attach to the Mayor session",
|
|
Long: `Attach to the running Mayor tmux session.
|
|
|
|
Attaches the current terminal to the Mayor's tmux session.
|
|
Detach with Ctrl-B D.`,
|
|
RunE: runMayorAttach,
|
|
}
|
|
|
|
var mayorStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Check Mayor session status",
|
|
Long: `Check if the Mayor tmux session is currently running.`,
|
|
RunE: runMayorStatus,
|
|
}
|
|
|
|
var mayorRestartCmd = &cobra.Command{
|
|
Use: "restart",
|
|
Short: "Restart the Mayor session",
|
|
Long: `Restart the Mayor tmux session.
|
|
|
|
Stops the current session (if running) and starts a fresh one.`,
|
|
RunE: runMayorRestart,
|
|
}
|
|
|
|
func init() {
|
|
mayorCmd.AddCommand(mayorStartCmd)
|
|
mayorCmd.AddCommand(mayorStopCmd)
|
|
mayorCmd.AddCommand(mayorAttachCmd)
|
|
mayorCmd.AddCommand(mayorStatusCmd)
|
|
mayorCmd.AddCommand(mayorRestartCmd)
|
|
|
|
rootCmd.AddCommand(mayorCmd)
|
|
}
|
|
|
|
func runMayorStart(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Check if session already exists
|
|
running, err := t.HasSession(MayorSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if running {
|
|
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
|
|
}
|
|
|
|
if err := startMayorSession(t); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("%s Mayor session started. Attach with: %s\n",
|
|
style.Bold.Render("✓"),
|
|
style.Dim.Render("gt mayor attach"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// startMayorSession creates and initializes the Mayor tmux session.
|
|
func startMayorSession(t *tmux.Tmux) error {
|
|
// Find workspace root
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Create session in workspace root
|
|
fmt.Println("Starting Mayor session...")
|
|
if err := t.NewSession(MayorSessionName, townRoot); err != nil {
|
|
return fmt.Errorf("creating session: %w", err)
|
|
}
|
|
|
|
// Set environment (non-fatal: session works without these)
|
|
_ = t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
|
|
_ = t.SetEnvironment(MayorSessionName, "BD_ACTOR", "mayor")
|
|
|
|
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
|
|
// Note: ConfigureGasTownSession includes cycle bindings
|
|
theme := tmux.MayorTheme()
|
|
_ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator")
|
|
|
|
// Launch Claude - the startup hook handles 'gt prime' automatically
|
|
// Use SendKeysDelayed to allow shell initialization after NewSession
|
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
|
// Mayor uses default runtime config (empty rigPath) since it's not rig-specific
|
|
claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "")
|
|
if err := t.SendKeysDelayed(MayorSessionName, claudeCmd, 200); err != nil {
|
|
return fmt.Errorf("sending command: %w", err)
|
|
}
|
|
|
|
// Wait for Claude to start (non-fatal)
|
|
if err := t.WaitForCommand(MayorSessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
|
// Non-fatal
|
|
}
|
|
time.Sleep(constants.ShutdownNotifyDelay)
|
|
|
|
// Inject startup nudge for predecessor discovery via /resume
|
|
_ = session.StartupNudge(t, MayorSessionName, session.StartupNudgeConfig{
|
|
Recipient: "mayor",
|
|
Sender: "human",
|
|
Topic: "cold-start",
|
|
}) // Non-fatal
|
|
|
|
// GUPP: Gas Town Universal Propulsion Principle
|
|
// Send the propulsion nudge to trigger autonomous coordination.
|
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
time.Sleep(2 * time.Second)
|
|
_ = t.NudgeSession(MayorSessionName, session.PropulsionNudgeForRole("mayor")) // Non-fatal
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMayorStop(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Check if session exists
|
|
running, err := t.HasSession(MayorSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return errors.New("Mayor session is not running")
|
|
}
|
|
|
|
fmt.Println("Stopping Mayor session...")
|
|
|
|
// Try graceful shutdown first (best-effort interrupt)
|
|
_ = t.SendKeysRaw(MayorSessionName, "C-c")
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Kill the session
|
|
if err := t.KillSession(MayorSessionName); err != nil {
|
|
return fmt.Errorf("killing session: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
func runMayorAttach(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Check if session exists
|
|
running, err := t.HasSession(MayorSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
// Auto-start if not running
|
|
fmt.Println("Mayor session not running, starting...")
|
|
if err := startMayorSession(t); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
|
return attachToTmuxSession(MayorSessionName)
|
|
}
|
|
|
|
func runMayorStatus(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
running, err := t.HasSession(MayorSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
|
|
if running {
|
|
// Get session info for more details
|
|
info, err := t.GetSessionInfo(MayorSessionName)
|
|
if err == nil {
|
|
status := "detached"
|
|
if info.Attached {
|
|
status = "attached"
|
|
}
|
|
fmt.Printf("%s Mayor session is %s\n",
|
|
style.Bold.Render("●"),
|
|
style.Bold.Render("running"))
|
|
fmt.Printf(" Status: %s\n", status)
|
|
fmt.Printf(" Created: %s\n", info.Created)
|
|
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach"))
|
|
} else {
|
|
fmt.Printf("%s Mayor session is %s\n",
|
|
style.Bold.Render("●"),
|
|
style.Bold.Render("running"))
|
|
}
|
|
} else {
|
|
fmt.Printf("%s Mayor session is %s\n",
|
|
style.Dim.Render("○"),
|
|
"not running")
|
|
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMayorRestart(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
running, err := t.HasSession(MayorSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
|
|
if running {
|
|
// Stop the current session (best-effort interrupt before kill)
|
|
fmt.Println("Stopping Mayor session...")
|
|
_ = t.SendKeysRaw(MayorSessionName, "C-c")
|
|
time.Sleep(100 * time.Millisecond)
|
|
if err := t.KillSession(MayorSessionName); err != nil {
|
|
return fmt.Errorf("killing session: %w", err)
|
|
}
|
|
}
|
|
|
|
// Start fresh
|
|
return runMayorStart(cmd, args)
|
|
}
|