Shell loops bypassed the proper lifecycle architecture. Now:
- Witness: Launches Claude directly, daemon/deacon health-scan handles restart
- Deacon: Launches Claude directly, daemon detects exit and restarts
- Daemon: ensureDeaconRunning() now checks if Claude is running (pane cmd)
and restarts it if session exists but Claude has exited
- gt deacon restart: Now does stop+start instead of Ctrl-C
This enforces proper lifecycle flow through LIFECYCLE mail and daemon
heartbeat rather than bypassing it with shell loops.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
303 lines
8.1 KiB
Go
303 lines
8.1 KiB
Go
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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)
|
|
}
|
|
|
|
// Deacon runs from its own directory (for correct role detection by gt prime)
|
|
deaconDir := filepath.Join(townRoot, "deacon")
|
|
|
|
// Ensure deacon directory exists
|
|
if err := os.MkdirAll(deaconDir, 0755); err != nil {
|
|
return fmt.Errorf("creating deacon directory: %w", err)
|
|
}
|
|
|
|
// Create session in deacon directory
|
|
fmt.Println("Starting Deacon session...")
|
|
if err := t.NewSession(DeaconSessionName, deaconDir); 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 directly (no shell respawn loop)
|
|
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
|
// The startup hook handles context loading automatically
|
|
if err := t.SendKeys(DeaconSessionName, "claude --dangerously-skip-permissions"); 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)
|
|
}
|
|
|
|
fmt.Println("Restarting Deacon...")
|
|
|
|
if running {
|
|
// Kill existing session
|
|
if err := t.KillSession(DeaconSessionName); err != nil {
|
|
fmt.Printf("%s Warning: failed to kill session: %v\n", style.Dim.Render("⚠"), err)
|
|
}
|
|
}
|
|
|
|
// Start fresh
|
|
if err := runDeaconStart(cmd, args); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("%s Deacon restarted\n", style.Bold.Render("✓"))
|
|
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt deacon attach' to connect"))
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|