Add initial prompt to deacon and witness startup commands to trigger autonomous patrol. Without this, agents sit idle at the prompt after SessionStart hooks run. Implements GUPP (Gas Town Universal Propulsion Principle): agents start patrol immediately when Claude launches.
186 lines
5.6 KiB
Go
186 lines
5.6 KiB
Go
package deacon
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/claude"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrNotRunning = errors.New("deacon not running")
|
|
ErrAlreadyRunning = errors.New("deacon already running")
|
|
)
|
|
|
|
// Manager handles deacon lifecycle operations.
|
|
type Manager struct {
|
|
townRoot string
|
|
}
|
|
|
|
// NewManager creates a new deacon manager for a town.
|
|
func NewManager(townRoot string) *Manager {
|
|
return &Manager{
|
|
townRoot: townRoot,
|
|
}
|
|
}
|
|
|
|
// SessionName returns the tmux session name for the deacon.
|
|
// This is a package-level function for convenience.
|
|
func SessionName() string {
|
|
return session.DeaconSessionName()
|
|
}
|
|
|
|
// SessionName returns the tmux session name for the deacon.
|
|
func (m *Manager) SessionName() string {
|
|
return SessionName()
|
|
}
|
|
|
|
// deaconDir returns the working directory for the deacon.
|
|
func (m *Manager) deaconDir() string {
|
|
return filepath.Join(m.townRoot, "deacon")
|
|
}
|
|
|
|
// Start starts the deacon session.
|
|
// agentOverride allows specifying an alternate agent alias (e.g., for testing).
|
|
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat.
|
|
func (m *Manager) Start(agentOverride string) error {
|
|
t := tmux.NewTmux()
|
|
sessionID := m.SessionName()
|
|
|
|
// Check if session already exists
|
|
running, _ := t.HasSession(sessionID)
|
|
if running {
|
|
// Session exists - check if Claude is actually running (healthy vs zombie)
|
|
if t.IsClaudeRunning(sessionID) {
|
|
return ErrAlreadyRunning
|
|
}
|
|
// Zombie - tmux alive but Claude dead. Kill and recreate.
|
|
if err := t.KillSession(sessionID); err != nil {
|
|
return fmt.Errorf("killing zombie session: %w", err)
|
|
}
|
|
}
|
|
|
|
// Ensure deacon directory exists
|
|
deaconDir := m.deaconDir()
|
|
if err := os.MkdirAll(deaconDir, 0755); err != nil {
|
|
return fmt.Errorf("creating deacon directory: %w", err)
|
|
}
|
|
|
|
// Ensure Claude settings exist
|
|
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
|
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
|
}
|
|
|
|
// Build startup command with initial prompt for autonomous patrol.
|
|
// The prompt triggers GUPP: deacon starts patrol immediately without waiting for input.
|
|
// This prevents the agent from sitting idle at the prompt after SessionStart hooks run.
|
|
initialPrompt := "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it."
|
|
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", m.townRoot, "", initialPrompt, agentOverride)
|
|
if err != nil {
|
|
return fmt.Errorf("building startup command: %w", err)
|
|
}
|
|
|
|
// Create session with command directly to avoid send-keys race condition.
|
|
// See: https://github.com/anthropics/gastown/issues/280
|
|
if err := t.NewSessionWithCommand(sessionID, deaconDir, startupCmd); err != nil {
|
|
return fmt.Errorf("creating tmux session: %w", err)
|
|
}
|
|
|
|
// Set environment variables (non-fatal: session works without these)
|
|
// Use centralized AgentEnv for consistency across all role startup paths
|
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
|
Role: "deacon",
|
|
TownRoot: m.townRoot,
|
|
})
|
|
for k, v := range envVars {
|
|
_ = t.SetEnvironment(sessionID, k, v)
|
|
}
|
|
|
|
// Apply Deacon theming (non-fatal: theming failure doesn't affect operation)
|
|
theme := tmux.DeaconTheme()
|
|
_ = t.ConfigureGasTownSession(sessionID, theme, "", "Deacon", "health-check")
|
|
|
|
// Wait for Claude to start - fatal if Claude fails to launch
|
|
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
|
// Kill the zombie session before returning error
|
|
_ = t.KillSessionWithProcesses(sessionID)
|
|
return fmt.Errorf("waiting for deacon to start: %w", err)
|
|
}
|
|
|
|
// Accept bypass permissions warning dialog if it appears.
|
|
_ = t.AcceptBypassPermissionsWarning(sessionID)
|
|
|
|
time.Sleep(constants.ShutdownNotifyDelay)
|
|
|
|
// Inject startup nudge for predecessor discovery via /resume
|
|
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
|
|
Recipient: "deacon",
|
|
Sender: "daemon",
|
|
Topic: "patrol",
|
|
}) // Non-fatal
|
|
|
|
// GUPP: Gas Town Universal Propulsion Principle
|
|
// Send the propulsion nudge to trigger autonomous patrol execution.
|
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
time.Sleep(2 * time.Second)
|
|
_ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the deacon session.
|
|
func (m *Manager) Stop() error {
|
|
t := tmux.NewTmux()
|
|
sessionID := m.SessionName()
|
|
|
|
// Check if session exists
|
|
running, err := t.HasSession(sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return ErrNotRunning
|
|
}
|
|
|
|
// Try graceful shutdown first (best-effort interrupt)
|
|
_ = t.SendKeysRaw(sessionID, "C-c")
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Kill the session
|
|
if err := t.KillSession(sessionID); err != nil {
|
|
return fmt.Errorf("killing session: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsRunning checks if the deacon session is active.
|
|
func (m *Manager) IsRunning() (bool, error) {
|
|
t := tmux.NewTmux()
|
|
return t.HasSession(m.SessionName())
|
|
}
|
|
|
|
// Status returns information about the deacon session.
|
|
func (m *Manager) Status() (*tmux.SessionInfo, error) {
|
|
t := tmux.NewTmux()
|
|
sessionID := m.SessionName()
|
|
|
|
running, err := t.HasSession(sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return nil, ErrNotRunning
|
|
}
|
|
|
|
return t.GetSessionInfo(sessionID)
|
|
}
|