Add WaitForShellReady call before SendKeys in all agent managers (deacon, mayor, witness, refinery). This prevents intermittent "can't find pane" errors that occur when the tmux session is created but the shell isn't ready to receive input yet. The issue manifests under load (e.g., during `gt up` when multiple agents start in sequence) where the 200ms delay in SendKeysDelayed isn't sufficient for the pane to be fully initialized. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
437 lines
12 KiB
Go
437 lines
12 KiB
Go
// Package polecat provides polecat workspace and session management.
|
|
package polecat
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/claude"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/rig"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
)
|
|
|
|
// debugSession logs non-fatal errors during session startup when GT_DEBUG_SESSION=1.
|
|
func debugSession(context string, err error) {
|
|
if os.Getenv("GT_DEBUG_SESSION") != "" && err != nil {
|
|
fmt.Fprintf(os.Stderr, "[session-debug] %s: %v\n", context, err)
|
|
}
|
|
}
|
|
|
|
// Session errors
|
|
var (
|
|
ErrSessionRunning = errors.New("session already running")
|
|
ErrSessionNotFound = errors.New("session not found")
|
|
)
|
|
|
|
// SessionManager handles polecat session lifecycle.
|
|
type SessionManager struct {
|
|
tmux *tmux.Tmux
|
|
rig *rig.Rig
|
|
}
|
|
|
|
// NewSessionManager creates a new polecat session manager for a rig.
|
|
func NewSessionManager(t *tmux.Tmux, r *rig.Rig) *SessionManager {
|
|
return &SessionManager{
|
|
tmux: t,
|
|
rig: r,
|
|
}
|
|
}
|
|
|
|
// SessionStartOptions configures polecat session startup.
|
|
type SessionStartOptions struct {
|
|
// WorkDir overrides the default working directory (polecat clone dir).
|
|
WorkDir string
|
|
|
|
// Issue is an optional issue ID to work on.
|
|
Issue string
|
|
|
|
// Command overrides the default "claude" command.
|
|
Command string
|
|
|
|
// Account specifies the account handle to use (overrides default).
|
|
Account string
|
|
|
|
// ClaudeConfigDir is resolved CLAUDE_CONFIG_DIR for the account.
|
|
// If set, this is injected as an environment variable.
|
|
ClaudeConfigDir string
|
|
}
|
|
|
|
// SessionInfo contains information about a running polecat session.
|
|
type SessionInfo struct {
|
|
// Polecat is the polecat name.
|
|
Polecat string `json:"polecat"`
|
|
|
|
// SessionID is the tmux session identifier.
|
|
SessionID string `json:"session_id"`
|
|
|
|
// Running indicates if the session is currently active.
|
|
Running bool `json:"running"`
|
|
|
|
// RigName is the rig this session belongs to.
|
|
RigName string `json:"rig_name"`
|
|
|
|
// Attached indicates if someone is attached to the session.
|
|
Attached bool `json:"attached,omitempty"`
|
|
|
|
// Created is when the session was created.
|
|
Created time.Time `json:"created,omitempty"`
|
|
|
|
// Windows is the number of tmux windows.
|
|
Windows int `json:"windows,omitempty"`
|
|
|
|
// LastActivity is when the session last had activity.
|
|
LastActivity time.Time `json:"last_activity,omitempty"`
|
|
}
|
|
|
|
// SessionName generates the tmux session name for a polecat.
|
|
func (m *SessionManager) SessionName(polecat string) string {
|
|
return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat)
|
|
}
|
|
|
|
// polecatDir returns the working directory for a polecat.
|
|
func (m *SessionManager) polecatDir(polecat string) string {
|
|
return filepath.Join(m.rig.Path, "polecats", polecat)
|
|
}
|
|
|
|
// hasPolecat checks if the polecat exists in this rig.
|
|
func (m *SessionManager) hasPolecat(polecat string) bool {
|
|
polecatPath := m.polecatDir(polecat)
|
|
info, err := os.Stat(polecatPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.IsDir()
|
|
}
|
|
|
|
// Start creates and starts a new session for a polecat.
|
|
func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|
if !m.hasPolecat(polecat) {
|
|
return fmt.Errorf("%w: %s", ErrPolecatNotFound, polecat)
|
|
}
|
|
|
|
sessionID := m.SessionName(polecat)
|
|
|
|
// Check if session already exists
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if running {
|
|
return fmt.Errorf("%w: %s", ErrSessionRunning, sessionID)
|
|
}
|
|
|
|
// Determine working directory
|
|
workDir := opts.WorkDir
|
|
if workDir == "" {
|
|
workDir = m.polecatDir(polecat)
|
|
}
|
|
|
|
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
|
|
// write into the source repo. Claude walks up the tree to find settings.
|
|
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
|
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
|
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
|
}
|
|
|
|
// Create session
|
|
if err := m.tmux.NewSession(sessionID, workDir); err != nil {
|
|
return fmt.Errorf("creating session: %w", err)
|
|
}
|
|
|
|
// Set environment (non-fatal: session works without these)
|
|
debugSession("SetEnvironment GT_RIG", m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name))
|
|
debugSession("SetEnvironment GT_POLECAT", m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat))
|
|
|
|
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
|
if opts.ClaudeConfigDir != "" {
|
|
debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir))
|
|
}
|
|
|
|
// Set beads environment for worktree polecats (non-fatal)
|
|
townRoot := filepath.Dir(m.rig.Path)
|
|
beadsDir := filepath.Join(townRoot, ".beads")
|
|
debugSession("SetEnvironment BEADS_DIR", m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir))
|
|
debugSession("SetEnvironment BEADS_NO_DAEMON", m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1"))
|
|
debugSession("SetEnvironment BEADS_AGENT_NAME", m.tmux.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", m.rig.Name, polecat)))
|
|
|
|
// Hook the issue to the polecat if provided via --issue flag
|
|
if opts.Issue != "" {
|
|
agentID := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
|
if err := m.hookIssue(opts.Issue, agentID, workDir); err != nil {
|
|
fmt.Printf("Warning: could not hook issue %s: %v\n", opts.Issue, err)
|
|
}
|
|
}
|
|
|
|
// Apply theme (non-fatal)
|
|
theme := tmux.AssignTheme(m.rig.Name)
|
|
debugSession("ConfigureGasTownSession", m.tmux.ConfigureGasTownSession(sessionID, theme, m.rig.Name, polecat, "polecat"))
|
|
|
|
// Set pane-died hook for crash detection (non-fatal)
|
|
agentID := fmt.Sprintf("%s/%s", m.rig.Name, polecat)
|
|
debugSession("SetPaneDiedHook", m.tmux.SetPaneDiedHook(sessionID, agentID))
|
|
|
|
// Send initial command with env vars exported inline
|
|
command := opts.Command
|
|
if command == "" {
|
|
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
|
|
}
|
|
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
|
if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
|
_ = m.tmux.KillSession(sessionID)
|
|
return fmt.Errorf("waiting for shell: %w", err)
|
|
}
|
|
if err := m.tmux.SendKeys(sessionID, command); err != nil {
|
|
return fmt.Errorf("sending command: %w", err)
|
|
}
|
|
|
|
// Wait for Claude to start (non-fatal)
|
|
debugSession("WaitForCommand", m.tmux.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout))
|
|
|
|
// Accept bypass permissions warning dialog if it appears
|
|
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
|
|
|
|
// Wait for Claude to be fully ready
|
|
time.Sleep(8 * time.Second)
|
|
|
|
// Inject startup nudge for predecessor discovery via /resume
|
|
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
|
debugSession("StartupNudge", session.StartupNudge(m.tmux, sessionID, session.StartupNudgeConfig{
|
|
Recipient: address,
|
|
Sender: "witness",
|
|
Topic: "assigned",
|
|
MolID: opts.Issue,
|
|
}))
|
|
|
|
// GUPP: Send propulsion nudge to trigger autonomous work execution
|
|
time.Sleep(2 * time.Second)
|
|
debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, session.PropulsionNudge()))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop terminates a polecat session.
|
|
func (m *SessionManager) Stop(polecat string, force bool) error {
|
|
sessionID := m.SessionName(polecat)
|
|
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return ErrSessionNotFound
|
|
}
|
|
|
|
// Sync beads before shutdown (non-fatal)
|
|
if !force {
|
|
polecatDir := m.polecatDir(polecat)
|
|
if err := m.syncBeads(polecatDir); err != nil {
|
|
fmt.Printf("Warning: beads sync failed: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Try graceful shutdown first
|
|
if !force {
|
|
_ = m.tmux.SendKeysRaw(sessionID, "C-c")
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
if err := m.tmux.KillSession(sessionID); err != nil {
|
|
return fmt.Errorf("killing session: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// syncBeads runs bd sync in the given directory.
|
|
func (m *SessionManager) syncBeads(workDir string) error {
|
|
cmd := exec.Command("bd", "sync")
|
|
cmd.Dir = workDir
|
|
return cmd.Run()
|
|
}
|
|
|
|
// IsRunning checks if a polecat session is active.
|
|
func (m *SessionManager) IsRunning(polecat string) (bool, error) {
|
|
sessionID := m.SessionName(polecat)
|
|
return m.tmux.HasSession(sessionID)
|
|
}
|
|
|
|
// Status returns detailed status for a polecat session.
|
|
func (m *SessionManager) Status(polecat string) (*SessionInfo, error) {
|
|
sessionID := m.SessionName(polecat)
|
|
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checking session: %w", err)
|
|
}
|
|
|
|
info := &SessionInfo{
|
|
Polecat: polecat,
|
|
SessionID: sessionID,
|
|
Running: running,
|
|
RigName: m.rig.Name,
|
|
}
|
|
|
|
if !running {
|
|
return info, nil
|
|
}
|
|
|
|
tmuxInfo, err := m.tmux.GetSessionInfo(sessionID)
|
|
if err != nil {
|
|
return info, nil
|
|
}
|
|
|
|
info.Attached = tmuxInfo.Attached
|
|
info.Windows = tmuxInfo.Windows
|
|
|
|
if tmuxInfo.Created != "" {
|
|
formats := []string{
|
|
"Mon Jan 2 15:04:05 2006",
|
|
"Mon Jan _2 15:04:05 2006",
|
|
time.ANSIC,
|
|
time.UnixDate,
|
|
}
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, tmuxInfo.Created); err == nil {
|
|
info.Created = t
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if tmuxInfo.Activity != "" {
|
|
var activityUnix int64
|
|
if _, err := fmt.Sscanf(tmuxInfo.Activity, "%d", &activityUnix); err == nil && activityUnix > 0 {
|
|
info.LastActivity = time.Unix(activityUnix, 0)
|
|
}
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// List returns information about all polecat sessions for this rig.
|
|
func (m *SessionManager) List() ([]SessionInfo, error) {
|
|
sessions, err := m.tmux.ListSessions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prefix := fmt.Sprintf("gt-%s-", m.rig.Name)
|
|
var infos []SessionInfo
|
|
|
|
for _, sessionID := range sessions {
|
|
if !strings.HasPrefix(sessionID, prefix) {
|
|
continue
|
|
}
|
|
|
|
polecat := strings.TrimPrefix(sessionID, prefix)
|
|
infos = append(infos, SessionInfo{
|
|
Polecat: polecat,
|
|
SessionID: sessionID,
|
|
Running: true,
|
|
RigName: m.rig.Name,
|
|
})
|
|
}
|
|
|
|
return infos, nil
|
|
}
|
|
|
|
// Attach attaches to a polecat session.
|
|
func (m *SessionManager) Attach(polecat string) error {
|
|
sessionID := m.SessionName(polecat)
|
|
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return ErrSessionNotFound
|
|
}
|
|
|
|
return m.tmux.AttachSession(sessionID)
|
|
}
|
|
|
|
// Capture returns the recent output from a polecat session.
|
|
func (m *SessionManager) Capture(polecat string, lines int) (string, error) {
|
|
sessionID := m.SessionName(polecat)
|
|
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return "", ErrSessionNotFound
|
|
}
|
|
|
|
return m.tmux.CapturePane(sessionID, lines)
|
|
}
|
|
|
|
// CaptureSession returns the recent output from a session by raw session ID.
|
|
func (m *SessionManager) CaptureSession(sessionID string, lines int) (string, error) {
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return "", ErrSessionNotFound
|
|
}
|
|
|
|
return m.tmux.CapturePane(sessionID, lines)
|
|
}
|
|
|
|
// Inject sends a message to a polecat session.
|
|
func (m *SessionManager) Inject(polecat, message string) error {
|
|
sessionID := m.SessionName(polecat)
|
|
|
|
running, err := m.tmux.HasSession(sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
if !running {
|
|
return ErrSessionNotFound
|
|
}
|
|
|
|
debounceMs := 200 + (len(message)/1024)*100
|
|
if debounceMs > 1500 {
|
|
debounceMs = 1500
|
|
}
|
|
|
|
return m.tmux.SendKeysDebounced(sessionID, message, debounceMs)
|
|
}
|
|
|
|
// StopAll terminates all polecat sessions for this rig.
|
|
func (m *SessionManager) StopAll(force bool) error {
|
|
infos, err := m.List()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var lastErr error
|
|
for _, info := range infos {
|
|
if err := m.Stop(info.Polecat, force); err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// hookIssue pins an issue to a polecat's hook using bd update.
|
|
func (m *SessionManager) hookIssue(issueID, agentID, workDir string) error {
|
|
cmd := exec.Command("bd", "update", issueID, "--status=hooked", "--assignee="+agentID) //nolint:gosec
|
|
cmd.Dir = workDir
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("bd update failed: %w", err)
|
|
}
|
|
fmt.Printf("✓ Hooked issue %s to %s\n", issueID, agentID)
|
|
return nil
|
|
}
|