feat(cmd): add gt deacon start/stop/status commands (gt-5af.2)
Add CLI commands for managing the Deacon session, following the same pattern as the Mayor commands: - gt deacon start: Start the Deacon tmux session - gt deacon stop: Stop the session with graceful shutdown - gt deacon status: Check if session is running - gt deacon attach: Attach to session (auto-starts if needed) - gt deacon restart: Restart Claude within the session The Deacon is the hierarchical health-check orchestrator that monitors Mayor and Witnesses, handles lifecycle requests, and keeps Gas Town running. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
245
internal/cmd/deacon.go
Normal file
245
internal/cmd/deacon.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"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,
|
||||
}
|
||||
|
||||
func init() {
|
||||
deaconCmd.AddCommand(deaconStartCmd)
|
||||
deaconCmd.AddCommand(deaconStopCmd)
|
||||
deaconCmd.AddCommand(deaconAttachCmd)
|
||||
deaconCmd.AddCommand(deaconStatusCmd)
|
||||
deaconCmd.AddCommand(deaconRestartCmd)
|
||||
|
||||
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")
|
||||
|
||||
// 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 exec to replace current process with tmux attach
|
||||
tmuxPath, err := exec.LookPath("tmux")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux not found: %w", err)
|
||||
}
|
||||
|
||||
return execCommand(tmuxPath, "attach-session", "-t", 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)
|
||||
}
|
||||
Reference in New Issue
Block a user