Files
gastown/internal/deacon/manager.go
aleiby 31bd120077 fix(startup): unify agent startup with beacon + instructions in CLI prompt (#977)
All agents now receive their startup beacon + role-specific instructions via
the CLI prompt, making sessions identifiable in /resume picker while removing
unreliable post-startup nudges.

Changes:
- Rename FormatStartupNudge → FormatStartupBeacon, StartupNudgeConfig → BeaconConfig
- Remove StartupNudge() function (no longer needed)
- Remove PropulsionNudge() and PropulsionNudgeForRole() functions
- Update deacon, witness, refinery, polecat managers to include beacon in CLI prompt
- Update boot to inline beacon (can't import session due to import cycle)
- Update daemon/lifecycle.go to include beacon via BuildCommandWithPrompt
- Update cmd/deacon.go to include beacon in startup command
- Remove redundant StartupNudge and PropulsionNudge calls from all startup paths

The beacon is now part of the CLI prompt which is queued before Claude starts,
making it more reliable than post-startup nudges which had timing issues.
SessionStart hook runs gt prime automatically, so PropulsionNudge was redundant.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:08:57 -08:00

177 lines
5.2 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.
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
if err := t.KillSessionWithProcesses(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)
}
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
Recipient: "deacon",
Sender: "daemon",
Topic: "patrol",
}, "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)
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.
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
// This prevents orphan bash processes from Claude's Bash tool surviving session termination.
if err := t.KillSessionWithProcesses(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)
}