Files
gastown/internal/polecat/session_manager.go
gastown/crew/max 9b2f4a7652 feat(polecat): add repo path to worktrees for LLM ergonomics (GH#283)
Changes polecat worktree structure from:
  polecats/<name>/
to:
  polecats/<name>/<rigname>/

This gives Claude Code agents a recognizable directory name (e.g., tidepool/)
in their cwd instead of just the polecat name, preventing confusion about
which repo they are working in.

Key changes:
- Add clonePath() method to manager.go and session_manager.go for the actual
  git worktree path, keeping polecatDir() for existence checks
- Update Add(), RepairWorktree(), Remove() to use new structure
- Update daemon lifecycle and restart code for new paths
- Update witness handlers to detect both structures
- Update doctor checks (rig_check, branch_check, config_check,
  claude_settings_check) for backward compatibility
- All code includes fallback to old structure for existing polecats

Fixes #283

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:16:10 -08:00

469 lines
14 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/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"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
// RuntimeConfigDir is resolved config directory for the runtime account.
// If set, this is injected as an environment variable.
RuntimeConfigDir 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 parent directory for a polecat.
// This is polecats/<name>/ - the polecat's home directory.
func (m *SessionManager) polecatDir(polecat string) string {
return filepath.Join(m.rig.Path, "polecats", polecat)
}
// clonePath returns the path where the git worktree lives.
// New structure: polecats/<name>/<rigname>/ - gives LLMs recognizable repo context.
// Falls back to old structure: polecats/<name>/ for backward compatibility.
func (m *SessionManager) clonePath(polecat string) string {
// New structure: polecats/<name>/<rigname>/
newPath := filepath.Join(m.rig.Path, "polecats", polecat, m.rig.Name)
if info, err := os.Stat(newPath); err == nil && info.IsDir() {
return newPath
}
// Old structure: polecats/<name>/ (backward compat)
oldPath := filepath.Join(m.rig.Path, "polecats", polecat)
if info, err := os.Stat(oldPath); err == nil && info.IsDir() {
// Check if this is actually a git worktree (has .git file or dir)
gitPath := filepath.Join(oldPath, ".git")
if _, err := os.Stat(gitPath); err == nil {
return oldPath
}
}
// Default to new structure for new polecats
return newPath
}
// 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.clonePath(polecat)
}
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
// Ensure runtime settings exist in polecats/ (not polecats/<name>/) so we don't
// write into the source repo. Runtime walks up the tree to find settings.
polecatsDir := filepath.Join(m.rig.Path, "polecats")
if err := runtime.EnsureSettingsForRole(polecatsDir, "polecat", runtimeConfig); err != nil {
return fmt.Errorf("ensuring runtime 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 runtime config dir for account selection (non-fatal)
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
debugSession("SetEnvironment "+runtimeConfig.Session.ConfigDirEnv, m.tmux.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, opts.RuntimeConfigDir))
}
// 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, "")
}
// Prepend runtime config dir env if needed
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
command = config.PrependEnv(command, map[string]string{runtimeConfig.Session.ConfigDirEnv: opts.RuntimeConfigDir})
}
// 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 runtime to be fully ready at the prompt (not just started)
runtime.SleepForReadyDelay(runtimeConfig)
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig)
// 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
}