454 lines
12 KiB
Go
454 lines
12 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/polecat"
|
|
"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"},
|
|
GroupID: GroupAgents,
|
|
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,
|
|
}
|
|
|
|
var deaconTriggerPendingCmd = &cobra.Command{
|
|
Use: "trigger-pending",
|
|
Short: "Trigger pending polecat spawns (bootstrap mode)",
|
|
Long: `Check inbox for POLECAT_STARTED messages and trigger ready polecats.
|
|
|
|
⚠️ BOOTSTRAP MODE ONLY - Uses regex detection (ZFC violation acceptable).
|
|
|
|
This command uses WaitForClaudeReady (regex) to detect when Claude is ready.
|
|
This is appropriate for daemon bootstrap when no AI is available.
|
|
|
|
In steady-state, the Deacon should use AI-based observation instead:
|
|
gt deacon pending # View pending spawns with captured output
|
|
gt peek <session> # Observe session output (AI analyzes)
|
|
gt nudge <session> # Trigger when AI determines ready
|
|
|
|
This command is typically called by the daemon during cold startup.`,
|
|
RunE: runDeaconTriggerPending,
|
|
}
|
|
|
|
|
|
var triggerTimeout time.Duration
|
|
|
|
func init() {
|
|
deaconCmd.AddCommand(deaconStartCmd)
|
|
deaconCmd.AddCommand(deaconStopCmd)
|
|
deaconCmd.AddCommand(deaconAttachCmd)
|
|
deaconCmd.AddCommand(deaconStatusCmd)
|
|
deaconCmd.AddCommand(deaconRestartCmd)
|
|
deaconCmd.AddCommand(deaconHeartbeatCmd)
|
|
deaconCmd.AddCommand(deaconTriggerPendingCmd)
|
|
|
|
// Flags for trigger-pending
|
|
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
|
"Timeout for checking if Claude is ready")
|
|
|
|
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)
|
|
}
|
|
|
|
// Ensure deacon has patrol hooks (idempotent)
|
|
if err := ensurePatrolHooks(deaconDir); err != nil {
|
|
style.PrintWarning("Could not create deacon hooks: %v", 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 (non-fatal: session works without these)
|
|
_ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
|
_ = t.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon")
|
|
|
|
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
|
// Note: ConfigureGasTownSession includes cycle bindings
|
|
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
|
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
|
if err := t.SendKeys(DeaconSessionName, "export GT_ROLE=deacon BD_ACTOR=deacon && 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 (best-effort interrupt)
|
|
_ = 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 {
|
|
style.PrintWarning("failed to kill session: %v", 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
|
|
}
|
|
|
|
func runDeaconTriggerPending(cmd *cobra.Command, args []string) error {
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Step 1: Check inbox for new POLECAT_STARTED messages
|
|
pending, err := polecat.CheckInboxForSpawns(townRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("checking inbox: %w", err)
|
|
}
|
|
|
|
if len(pending) == 0 {
|
|
fmt.Printf("%s No pending spawns\n", style.Dim.Render("○"))
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%s Found %d pending spawn(s)\n", style.Bold.Render("●"), len(pending))
|
|
|
|
// Step 2: Try to trigger each pending spawn
|
|
results, err := polecat.TriggerPendingSpawns(townRoot, triggerTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("triggering: %w", err)
|
|
}
|
|
|
|
// Report results
|
|
triggered := 0
|
|
for _, r := range results {
|
|
if r.Triggered {
|
|
triggered++
|
|
fmt.Printf(" %s Triggered %s/%s\n",
|
|
style.Bold.Render("✓"),
|
|
r.Spawn.Rig, r.Spawn.Polecat)
|
|
} else if r.Error != nil {
|
|
fmt.Printf(" %s %s/%s: %v\n",
|
|
style.Dim.Render("⚠"),
|
|
r.Spawn.Rig, r.Spawn.Polecat, r.Error)
|
|
}
|
|
}
|
|
|
|
// Step 3: Prune stale pending spawns (older than 5 minutes)
|
|
pruned, _ := polecat.PruneStalePending(townRoot, 5*time.Minute)
|
|
if pruned > 0 {
|
|
fmt.Printf(" %s Pruned %d stale spawn(s)\n", style.Dim.Render("○"), pruned)
|
|
}
|
|
|
|
// Summary
|
|
remaining := len(pending) - triggered
|
|
if remaining > 0 {
|
|
fmt.Printf("%s %d spawn(s) still waiting for Claude\n",
|
|
style.Dim.Render("○"), remaining)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensurePatrolHooks creates .claude/settings.json with hooks for patrol roles.
|
|
// This is idempotent - if hooks already exist, it does nothing.
|
|
func ensurePatrolHooks(workspacePath string) error {
|
|
settingsPath := filepath.Join(workspacePath, ".claude", "settings.json")
|
|
|
|
// Check if already exists
|
|
if _, err := os.Stat(settingsPath); err == nil {
|
|
return nil // Already exists
|
|
}
|
|
|
|
claudeDir := filepath.Join(workspacePath, ".claude")
|
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
|
return fmt.Errorf("creating .claude dir: %w", err)
|
|
}
|
|
|
|
// Standard patrol hooks
|
|
hooksJSON := `{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"matcher": "",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "gt prime && gt mail check --inject"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PreCompact": [
|
|
{
|
|
"matcher": "",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "gt prime"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"UserPromptSubmit": [
|
|
{
|
|
"matcher": "",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "gt mail check --inject"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
`
|
|
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
|
|
}
|
|
|