When 'gt X attach' is run from inside a tmux session, link the target session's window as a new tab instead of switching sessions entirely. Use C-b n/p to navigate between tabs. Outside tmux: unchanged behavior (full attach) Inside tmux: links window as tab for convenient multi-session viewing - Add tmux.LinkWindow() and tmux.IsInsideTmux() - Update attachToTmuxSession() with smart detection - Update mayor, deacon, crew, refinery attach commands
287 lines
7.9 KiB
Go
287 lines
7.9 KiB
Go
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
// DeaconSessionName is the tmux session name for the Deacon.
|
|
const DeaconSessionName = "gt-deacon"
|
|
|
|
var deaconCmd = &cobra.Command{
|
|
Use: "deacon",
|
|
Aliases: []string{"dea"},
|
|
Short: "Manage the Deacon session",
|
|
Long: `Manage the Deacon tmux session.
|
|
|
|
The Deacon is the hierarchical health-check orchestrator for Gas Town.
|
|
It monitors the Mayor and Witnesses, handles lifecycle requests, and
|
|
keeps the town running. Use the subcommands to start, stop, attach,
|
|
and check status.`,
|
|
}
|
|
|
|
var deaconStartCmd = &cobra.Command{
|
|
Use: "start",
|
|
Short: "Start the Deacon session",
|
|
Long: `Start the Deacon tmux session.
|
|
|
|
Creates a new detached tmux session for the Deacon and launches Claude.
|
|
The session runs in the workspace root directory.`,
|
|
RunE: runDeaconStart,
|
|
}
|
|
|
|
var deaconStopCmd = &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the Deacon session",
|
|
Long: `Stop the Deacon tmux session.
|
|
|
|
Attempts graceful shutdown first (Ctrl-C), then kills the tmux session.`,
|
|
RunE: runDeaconStop,
|
|
}
|
|
|
|
var deaconAttachCmd = &cobra.Command{
|
|
Use: "attach",
|
|
Aliases: []string{"at"},
|
|
Short: "Attach to the Deacon session",
|
|
Long: `Attach to the running Deacon tmux session.
|
|
|
|
Attaches the current terminal to the Deacon's tmux session.
|
|
Detach with Ctrl-B D.`,
|
|
RunE: runDeaconAttach,
|
|
}
|
|
|
|
var deaconStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Check Deacon session status",
|
|
Long: `Check if the Deacon tmux session is currently running.`,
|
|
RunE: runDeaconStatus,
|
|
}
|
|
|
|
var deaconRestartCmd = &cobra.Command{
|
|
Use: "restart",
|
|
Short: "Restart the Deacon session",
|
|
Long: `Restart the Deacon tmux session.
|
|
|
|
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)
|
|
}
|
|
|
|
func runDeaconStart(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Check if session already exists
|
|
running, err := t.HasSession(DeaconSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if running {
|
|
return fmt.Errorf("Deacon session already running. Attach with: gt deacon attach")
|
|
}
|
|
|
|
if err := startDeaconSession(t); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("%s Deacon session started. Attach with: %s\n",
|
|
style.Bold.Render("✓"),
|
|
style.Dim.Render("gt deacon attach"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// startDeaconSession creates and initializes the Deacon tmux session.
|
|
func startDeaconSession(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 Deacon session...")
|
|
if err := t.NewSession(DeaconSessionName, townRoot); err != nil {
|
|
return fmt.Errorf("creating session: %w", err)
|
|
}
|
|
|
|
// 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
|
|
loopCmd := `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`
|
|
if err := t.SendKeysDelayed(DeaconSessionName, loopCmd, 200); err != nil {
|
|
return fmt.Errorf("sending command: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runDeaconStop(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Check if session exists
|
|
running, err := t.HasSession(DeaconSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return errors.New("Deacon session is not running")
|
|
}
|
|
|
|
fmt.Println("Stopping Deacon session...")
|
|
|
|
// Try graceful shutdown first
|
|
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Kill the session
|
|
if err := t.KillSession(DeaconSessionName); err != nil {
|
|
return fmt.Errorf("killing session: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Deacon session stopped.\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
func runDeaconAttach(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Check if session exists
|
|
running, err := t.HasSession(DeaconSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
// Auto-start if not running
|
|
fmt.Println("Deacon session not running, starting...")
|
|
if err := startDeaconSession(t); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Session uses a respawn loop, so Claude restarts automatically if it exits
|
|
|
|
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
|
return attachToTmuxSession(DeaconSessionName)
|
|
}
|
|
|
|
func runDeaconStatus(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
running, err := t.HasSession(DeaconSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
|
|
if running {
|
|
// Get session info for more details
|
|
info, err := t.GetSessionInfo(DeaconSessionName)
|
|
if err == nil {
|
|
status := "detached"
|
|
if info.Attached {
|
|
status = "attached"
|
|
}
|
|
fmt.Printf("%s Deacon 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 deacon attach"))
|
|
} else {
|
|
fmt.Printf("%s Deacon session is %s\n",
|
|
style.Bold.Render("●"),
|
|
style.Bold.Render("running"))
|
|
}
|
|
} else {
|
|
fmt.Printf("%s Deacon session is %s\n",
|
|
style.Dim.Render("○"),
|
|
"not running")
|
|
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt deacon start"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
running, err := t.HasSession(DeaconSessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
|
|
if running {
|
|
// Graceful restart: send Ctrl-C to exit Claude, loop will restart it
|
|
fmt.Println("Restarting Deacon (sending Ctrl-C to trigger respawn loop)...")
|
|
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
|
|
fmt.Printf("%s Deacon will restart automatically. Session stays attached.\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|