Rename gt recycle to gt handoff
Consistent naming throughout: - internal/cmd/recycle.go → handoff.go - All variable/function names updated - /handoff Claude Code command updated - polecat.md prompt updated Also includes session.go doc improvements (nudge preference). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,533 +1,240 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// HandoffAction for handoff command.
|
||||
type HandoffAction string
|
||||
|
||||
const (
|
||||
HandoffCycle HandoffAction = "cycle" // Restart with handoff mail
|
||||
HandoffRestart HandoffAction = "restart" // Fresh restart, no handoff
|
||||
HandoffShutdown HandoffAction = "shutdown" // Terminate, no restart
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
var handoffCmd = &cobra.Command{
|
||||
Use: "handoff",
|
||||
Short: "Request lifecycle action (retirement/restart)",
|
||||
Long: `Request a lifecycle action from your manager.
|
||||
Use: "handoff [role]",
|
||||
Short: "Hand off to a fresh session, work continues from hook",
|
||||
Long: `End watch. Hand off to a fresh agent session.
|
||||
|
||||
This command initiates graceful retirement:
|
||||
1. Verifies git state is clean
|
||||
2. For polecats (shutdown): auto-submits MR to merge queue
|
||||
3. Sends handoff mail to yourself (for cycle)
|
||||
4. Sends lifecycle request to your manager
|
||||
5. Sets requesting state and waits for retirement
|
||||
This command uses tmux respawn-pane to end the current session and restart it
|
||||
with a fresh Claude instance, running the full startup/priming sequence.
|
||||
|
||||
Your manager (daemon for Mayor/Witness, witness for polecats) will
|
||||
verify the request and terminate your session. For cycle/restart,
|
||||
a new session starts and reads your handoff mail to continue work.
|
||||
|
||||
Polecat auto-MR:
|
||||
When a polecat runs 'gt handoff' (default: shutdown), the current branch
|
||||
is automatically submitted to the merge queue if it follows the
|
||||
polecat/<name>/<issue> naming convention. The Refinery will process
|
||||
the merge request.
|
||||
|
||||
Flags:
|
||||
--cycle Restart with handoff mail (default for Mayor/Witness)
|
||||
--restart Fresh restart, no handoff context
|
||||
--shutdown Terminate without restart (default for polecats)
|
||||
When run without arguments, hands off the current session.
|
||||
When given a role name, hands off that role's session (and switches to it).
|
||||
|
||||
Examples:
|
||||
gt handoff # Use role-appropriate default
|
||||
gt handoff --cycle # Restart with context handoff
|
||||
gt handoff --restart # Fresh restart
|
||||
`,
|
||||
gt handoff # Hand off current session
|
||||
gt handoff crew # Hand off crew session (auto-detect name)
|
||||
gt handoff mayor # Hand off mayor session
|
||||
gt handoff witness # Hand off witness session for current rig
|
||||
|
||||
Any molecule on the hook will be auto-continued by the new session.`,
|
||||
RunE: runHandoff,
|
||||
}
|
||||
|
||||
var (
|
||||
handoffCycle bool
|
||||
handoffRestart bool
|
||||
handoffShutdown bool
|
||||
handoffForce bool
|
||||
handoffMessage string
|
||||
handoffWatch bool
|
||||
handoffDryRun bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
handoffCmd.Flags().BoolVar(&handoffCycle, "cycle", false, "Restart with handoff mail")
|
||||
handoffCmd.Flags().BoolVar(&handoffRestart, "restart", false, "Fresh restart, no handoff")
|
||||
handoffCmd.Flags().BoolVar(&handoffShutdown, "shutdown", false, "Terminate without restart")
|
||||
handoffCmd.Flags().BoolVarP(&handoffForce, "force", "f", false, "Skip pre-flight checks")
|
||||
handoffCmd.Flags().StringVarP(&handoffMessage, "message", "m", "", "Handoff message for successor")
|
||||
|
||||
handoffCmd.Flags().BoolVarP(&handoffWatch, "watch", "w", true, "Switch to new session (for remote handoff)")
|
||||
handoffCmd.Flags().BoolVarP(&handoffDryRun, "dry-run", "n", false, "Show what would be done without executing")
|
||||
rootCmd.AddCommand(handoffCmd)
|
||||
}
|
||||
|
||||
func runHandoff(cmd *cobra.Command, args []string) error {
|
||||
// Detect our role
|
||||
role := detectHandoffRole()
|
||||
if role == RoleUnknown {
|
||||
return fmt.Errorf("cannot detect agent role (set GT_ROLE or run from known context)")
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Verify we're in tmux
|
||||
if !tmux.IsInsideTmux() {
|
||||
return fmt.Errorf("not running in tmux - cannot hand off")
|
||||
}
|
||||
|
||||
// Determine action
|
||||
action := determineAction(role)
|
||||
pane := os.Getenv("TMUX_PANE")
|
||||
if pane == "" {
|
||||
return fmt.Errorf("TMUX_PANE not set - cannot hand off")
|
||||
}
|
||||
|
||||
fmt.Printf("Agent role: %s\n", style.Bold.Render(string(role)))
|
||||
fmt.Printf("Action: %s\n", style.Bold.Render(string(action)))
|
||||
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
// Get current session name
|
||||
currentSession, err := getCurrentTmuxSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
return fmt.Errorf("getting session name: %w", err)
|
||||
}
|
||||
|
||||
// Pre-flight checks (unless forced)
|
||||
if !handoffForce {
|
||||
if err := preFlightChecks(); err != nil {
|
||||
return fmt.Errorf("pre-flight check failed: %w\n\nUse --force to skip checks", err)
|
||||
// Determine target session
|
||||
targetSession := currentSession
|
||||
if len(args) > 0 {
|
||||
// User specified a role to hand off
|
||||
targetSession, err = resolveRoleToSession(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// For polecats shutting down with work complete, auto-submit MR to merge queue
|
||||
if role == RolePolecat && action == HandoffShutdown {
|
||||
if err := submitMRForPolecat(); err != nil {
|
||||
// Non-fatal: warn but continue with handoff
|
||||
fmt.Printf("%s Could not auto-submit MR: %v\n", style.Warning.Render("Warning:"), err)
|
||||
fmt.Println(style.Dim.Render(" You may need to run 'gt mq submit' manually"))
|
||||
} else {
|
||||
fmt.Printf("%s Auto-submitted work to merge queue\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
|
||||
// For cycle, update handoff bead for successor
|
||||
if action == HandoffCycle {
|
||||
if err := sendHandoffMail(role, townRoot); err != nil {
|
||||
return fmt.Errorf("updating handoff bead: %w", err)
|
||||
}
|
||||
fmt.Printf("%s Updated handoff bead for successor\n", style.Bold.Render("✓"))
|
||||
}
|
||||
|
||||
// Send lifecycle request to manager
|
||||
manager := getManager(role)
|
||||
|
||||
if err := sendLifecycleRequest(manager, role, action, townRoot); err != nil {
|
||||
return fmt.Errorf("sending lifecycle request: %w", err)
|
||||
}
|
||||
fmt.Printf("%s Sent %s request to %s\n", style.Bold.Render("✓"), action, manager)
|
||||
|
||||
// Signal daemon for immediate processing (if manager is deacon)
|
||||
if manager == "deacon/" {
|
||||
if err := signalDaemon(townRoot); err != nil {
|
||||
// Non-fatal: daemon will eventually poll
|
||||
fmt.Printf("%s Could not signal daemon (will poll): %v\n", style.Dim.Render("○"), err)
|
||||
} else {
|
||||
fmt.Printf("%s Signaled daemon for immediate processing\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
|
||||
// Set requesting state
|
||||
if err := setRequestingState(role, action, townRoot); err != nil {
|
||||
fmt.Printf("Warning: failed to set state: %v\n", err)
|
||||
}
|
||||
|
||||
// Wait for retirement with timeout warning
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Waiting for retirement...\n", style.Dim.Render("◌"))
|
||||
fmt.Println(style.Dim.Render("(Manager will terminate this session)"))
|
||||
|
||||
// Wait with periodic warnings - manager should kill us
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
waitStart := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
elapsed := time.Since(waitStart).Round(time.Second)
|
||||
fmt.Printf("%s Still waiting (%v elapsed)...\n", style.Dim.Render("◌"), elapsed)
|
||||
if elapsed >= 2*time.Minute {
|
||||
fmt.Println(style.Dim.Render(" Hint: If manager isn't responding, you may need to:"))
|
||||
fmt.Println(style.Dim.Render(" - Check if daemon/witness is running"))
|
||||
fmt.Println(style.Dim.Render(" - Use Ctrl+C to abort and manually exit"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detectHandoffRole figures out what kind of agent we are.
|
||||
// Uses GT_ROLE env var, tmux session name, or directory context.
|
||||
func detectHandoffRole() Role {
|
||||
// Check GT_ROLE environment variable first
|
||||
if role := os.Getenv("GT_ROLE"); role != "" {
|
||||
switch strings.ToLower(role) {
|
||||
case "mayor":
|
||||
return RoleMayor
|
||||
case "witness":
|
||||
return RoleWitness
|
||||
case "refinery":
|
||||
return RoleRefinery
|
||||
case "polecat":
|
||||
return RolePolecat
|
||||
case "crew":
|
||||
return RoleCrew
|
||||
}
|
||||
}
|
||||
|
||||
// Check tmux session name
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
|
||||
if err == nil {
|
||||
sessionName := strings.TrimSpace(string(out))
|
||||
if sessionName == "gt-mayor" {
|
||||
return RoleMayor
|
||||
}
|
||||
if strings.HasSuffix(sessionName, "-witness") {
|
||||
return RoleWitness
|
||||
}
|
||||
if strings.HasSuffix(sessionName, "-refinery") {
|
||||
return RoleRefinery
|
||||
}
|
||||
// Crew sessions: gt-<rig>-crew-<name>
|
||||
if strings.Contains(sessionName, "-crew-") {
|
||||
return RoleCrew
|
||||
}
|
||||
// Polecat sessions: gt-<rig>-<name>
|
||||
if strings.HasPrefix(sessionName, "gt-") && strings.Count(sessionName, "-") >= 2 {
|
||||
return RolePolecat
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to directory-based detection
|
||||
cwd, err := os.Getwd()
|
||||
// Build the restart command
|
||||
restartCmd, err := buildRestartCommand(targetSession)
|
||||
if err != nil {
|
||||
return RoleUnknown
|
||||
return err
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil || townRoot == "" {
|
||||
return RoleUnknown
|
||||
// If handing off a different session, we need to find its pane and respawn there
|
||||
if targetSession != currentSession {
|
||||
return handoffRemoteSession(t, targetSession, restartCmd)
|
||||
}
|
||||
|
||||
ctx := detectRole(cwd, townRoot)
|
||||
return ctx.Role
|
||||
}
|
||||
// Handing off ourselves - print feedback then respawn
|
||||
fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), currentSession)
|
||||
|
||||
// determineAction picks the action based on flags or role default.
|
||||
func determineAction(role Role) HandoffAction {
|
||||
// Explicit flags take precedence
|
||||
if handoffCycle {
|
||||
return HandoffCycle
|
||||
}
|
||||
if handoffRestart {
|
||||
return HandoffRestart
|
||||
}
|
||||
if handoffShutdown {
|
||||
return HandoffShutdown
|
||||
}
|
||||
|
||||
// Role-based defaults
|
||||
switch role {
|
||||
case RolePolecat:
|
||||
return HandoffShutdown // Ephemeral, work is done
|
||||
case RoleMayor, RoleWitness, RoleRefinery:
|
||||
return HandoffCycle // Long-running, preserve context
|
||||
case RoleCrew:
|
||||
return HandoffCycle // Persistent workspace, preserve context
|
||||
default:
|
||||
return HandoffCycle
|
||||
}
|
||||
}
|
||||
|
||||
// preFlightChecks verifies it's safe to retire.
|
||||
func preFlightChecks() error {
|
||||
// Check git status
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Not a git repo, that's fine
|
||||
// Dry run mode - show what would happen
|
||||
if handoffDryRun {
|
||||
fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", pane, restartCmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(string(out))) > 0 {
|
||||
return fmt.Errorf("uncommitted changes in git working tree")
|
||||
}
|
||||
|
||||
return nil
|
||||
// Use exec to respawn the pane - this kills us and restarts
|
||||
return t.RespawnPane(pane, restartCmd)
|
||||
}
|
||||
|
||||
// getManager returns the address of our lifecycle manager.
|
||||
// For polecats and refineries, it detects the rig from context.
|
||||
func getManager(role Role) string {
|
||||
switch role {
|
||||
case RoleMayor, RoleWitness:
|
||||
return "deacon/"
|
||||
case RolePolecat, RoleRefinery:
|
||||
// Detect rig from current directory context
|
||||
rig := detectRigFromContext()
|
||||
// getCurrentTmuxSession returns the current tmux session name.
|
||||
func getCurrentTmuxSession() (string, error) {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// resolveRoleToSession converts a role name to a tmux session name.
|
||||
// For roles that need context (crew, witness, refinery), it auto-detects from environment.
|
||||
func resolveRoleToSession(role string) (string, error) {
|
||||
switch strings.ToLower(role) {
|
||||
case "mayor", "may":
|
||||
return "gt-mayor", nil
|
||||
|
||||
case "deacon", "dea":
|
||||
return "gt-deacon", nil
|
||||
|
||||
case "crew":
|
||||
// Try to get rig and crew name from environment or cwd
|
||||
rig := os.Getenv("GT_RIG")
|
||||
crewName := os.Getenv("GT_CREW")
|
||||
if rig == "" || crewName == "" {
|
||||
// Try to detect from cwd
|
||||
detected, err := detectCrewFromCwd()
|
||||
if err == nil {
|
||||
rig = detected.rigName
|
||||
crewName = detected.crewName
|
||||
}
|
||||
}
|
||||
if rig == "" || crewName == "" {
|
||||
return "", fmt.Errorf("cannot determine crew identity - run from crew directory or specify GT_RIG/GT_CREW")
|
||||
}
|
||||
return fmt.Sprintf("gt-%s-crew-%s", rig, crewName), nil
|
||||
|
||||
case "witness", "wit":
|
||||
rig := os.Getenv("GT_RIG")
|
||||
if rig == "" {
|
||||
// Fallback if rig detection fails - this shouldn't happen
|
||||
// in normal operation but is better than a literal placeholder
|
||||
return "deacon/"
|
||||
return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context")
|
||||
}
|
||||
return rig + "/witness"
|
||||
case RoleCrew:
|
||||
return "deacon/" // Crew lifecycle managed by deacon
|
||||
return fmt.Sprintf("gt-%s-witness", rig), nil
|
||||
|
||||
case "refinery", "ref":
|
||||
rig := os.Getenv("GT_RIG")
|
||||
if rig == "" {
|
||||
return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context")
|
||||
}
|
||||
return fmt.Sprintf("gt-%s-refinery", rig), nil
|
||||
|
||||
default:
|
||||
return "deacon/"
|
||||
// Assume it's a direct session name
|
||||
return role, nil
|
||||
}
|
||||
}
|
||||
|
||||
// detectRigFromContext determines the rig name from the current directory.
|
||||
func detectRigFromContext() string {
|
||||
cwd, err := os.Getwd()
|
||||
// buildRestartCommand creates the gt command to restart a session.
|
||||
func buildRestartCommand(sessionName string) (string, error) {
|
||||
switch {
|
||||
case sessionName == "gt-mayor":
|
||||
return "gt may at", nil
|
||||
|
||||
case sessionName == "gt-deacon":
|
||||
return "gt dea at", nil
|
||||
|
||||
case strings.Contains(sessionName, "-crew-"):
|
||||
// gt-<rig>-crew-<name>
|
||||
// The attach command can auto-detect from cwd, so just use `gt crew at`
|
||||
return "gt crew at", nil
|
||||
|
||||
case strings.HasSuffix(sessionName, "-witness"):
|
||||
// gt-<rig>-witness
|
||||
return "gt wit at", nil
|
||||
|
||||
case strings.HasSuffix(sessionName, "-refinery"):
|
||||
// gt-<rig>-refinery
|
||||
return "gt ref at", nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown session type: %s (try specifying role explicitly)", sessionName)
|
||||
}
|
||||
}
|
||||
|
||||
// handoffRemoteSession respawns a different session and optionally switches to it.
|
||||
func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error {
|
||||
// Check if target session exists
|
||||
exists, err := t.HasSession(targetSession)
|
||||
if err != nil {
|
||||
return ""
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("session '%s' not found - is the agent running?", targetSession)
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil || townRoot == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx := detectRole(cwd, townRoot)
|
||||
return ctx.Rig
|
||||
}
|
||||
|
||||
// sendHandoffMail updates the pinned handoff bead for the successor to read.
|
||||
func sendHandoffMail(role Role, townRoot string) error {
|
||||
// Build handoff content
|
||||
content := handoffMessage
|
||||
if content == "" {
|
||||
content = fmt.Sprintf(`🤝 HANDOFF: Session cycling
|
||||
|
||||
Time: %s
|
||||
Role: %s
|
||||
Action: cycle
|
||||
|
||||
Check bd ready for pending work.
|
||||
Check gt mail inbox for messages received during transition.
|
||||
`, time.Now().Format(time.RFC3339), role)
|
||||
}
|
||||
|
||||
// Determine the handoff role key
|
||||
// For role-specific handoffs, use the role name
|
||||
roleKey := string(role)
|
||||
|
||||
// Update the pinned handoff bead
|
||||
bd := beads.New(townRoot)
|
||||
if err := bd.UpdateHandoffContent(roleKey, content); err != nil {
|
||||
return fmt.Errorf("updating handoff bead: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPolecatName extracts the polecat name from the tmux session.
|
||||
// Returns empty string if not a polecat session.
|
||||
func getPolecatName() string {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
|
||||
// Get the pane ID for the target session
|
||||
targetPane, err := getSessionPane(targetSession)
|
||||
if err != nil {
|
||||
return ""
|
||||
return fmt.Errorf("getting target pane: %w", err)
|
||||
}
|
||||
sessionName := strings.TrimSpace(string(out))
|
||||
|
||||
// Polecat sessions: gt-<rig>-<name>
|
||||
if strings.HasPrefix(sessionName, "gt-") {
|
||||
parts := strings.SplitN(sessionName, "-", 3)
|
||||
if len(parts) >= 3 {
|
||||
return parts[2] // The polecat name
|
||||
fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), targetSession)
|
||||
|
||||
// Dry run mode
|
||||
if handoffDryRun {
|
||||
fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", targetPane, restartCmd)
|
||||
if handoffWatch {
|
||||
fmt.Printf("Would execute: tmux switch-client -t %s\n", targetSession)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCrewIdentity extracts the crew identity from the tmux session.
|
||||
// Returns format: <rig>-crew-<name> (e.g., gastown-crew-max)
|
||||
func getCrewIdentity() string {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sessionName := strings.TrimSpace(string(out))
|
||||
|
||||
// Crew sessions: gt-<rig>-crew-<name>
|
||||
if strings.HasPrefix(sessionName, "gt-") && strings.Contains(sessionName, "-crew-") {
|
||||
// Remove "gt-" prefix to get <rig>-crew-<name>
|
||||
return strings.TrimPrefix(sessionName, "gt-")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sendLifecycleRequest sends the lifecycle request to our manager.
|
||||
func sendLifecycleRequest(manager string, role Role, action HandoffAction, townRoot string) error {
|
||||
// Build identity for the LIFECYCLE message
|
||||
// The daemon parses identity from "LIFECYCLE: <identity> requesting <action>"
|
||||
identity := string(role)
|
||||
|
||||
switch role {
|
||||
case RoleCrew:
|
||||
// Crew identity: <rig>-crew-<name> (e.g., gastown-crew-max)
|
||||
if crewID := getCrewIdentity(); crewID != "" {
|
||||
identity = crewID
|
||||
}
|
||||
case RolePolecat:
|
||||
// Polecat identity would need similar handling if routed to deacon
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", identity, action)
|
||||
body := fmt.Sprintf(`Lifecycle request from %s.
|
||||
|
||||
Action: %s
|
||||
Time: %s
|
||||
|
||||
Please verify state and execute lifecycle action.
|
||||
`, identity, action, time.Now().Format(time.RFC3339))
|
||||
|
||||
// Send via gt mail (syntax: gt mail send <recipient> -s <subject> -m <body>)
|
||||
cmd := exec.Command("gt", "mail", "send", manager,
|
||||
"-s", subject,
|
||||
"-m", body,
|
||||
)
|
||||
cmd.Dir = townRoot
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// submitMRForPolecat submits the current branch to the merge queue.
|
||||
// This is called automatically when a polecat shuts down with completed work.
|
||||
func submitMRForPolecat() error {
|
||||
// Check if we're on a polecat branch with work to submit
|
||||
cmd := exec.Command("git", "branch", "--show-current")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting current branch: %w", err)
|
||||
}
|
||||
branch := strings.TrimSpace(string(out))
|
||||
|
||||
// Skip if on main/master (no work to submit)
|
||||
if branch == "main" || branch == "master" || branch == "" {
|
||||
return nil // Nothing to submit, that's OK
|
||||
}
|
||||
|
||||
// Check if branch follows polecat/<name>/<issue> pattern
|
||||
parts := strings.Split(branch, "/")
|
||||
if len(parts) < 3 || parts[0] != "polecat" {
|
||||
// Not a polecat work branch, skip
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run gt mq submit --no-cleanup (handoff manages lifecycle itself)
|
||||
submitCmd := exec.Command("gt", "mq", "submit", "--no-cleanup")
|
||||
submitOutput, err := submitCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(submitOutput)))
|
||||
// Respawn the remote session's pane
|
||||
if err := t.RespawnPane(targetPane, restartCmd); err != nil {
|
||||
return fmt.Errorf("respawning pane: %w", err)
|
||||
}
|
||||
|
||||
// Print the submit output (trimmed)
|
||||
output := strings.TrimSpace(string(submitOutput))
|
||||
if output != "" {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
// If --watch, switch to that session
|
||||
if handoffWatch {
|
||||
fmt.Printf("Switching to %s...\n", targetSession)
|
||||
// Use tmux switch-client to move our view to the target session
|
||||
if err := exec.Command("tmux", "switch-client", "-t", targetSession).Run(); err != nil {
|
||||
// Non-fatal - they can manually switch
|
||||
fmt.Printf("Note: Could not auto-switch (use: tmux switch-client -t %s)\n", targetSession)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setRequestingState updates state.json to indicate we're requesting lifecycle action.
|
||||
func setRequestingState(role Role, action HandoffAction, townRoot string) error {
|
||||
// Determine state file location based on role
|
||||
var stateFile string
|
||||
switch role {
|
||||
case RoleMayor:
|
||||
stateFile = filepath.Join(townRoot, "mayor", "state.json")
|
||||
case RoleWitness:
|
||||
// Would need rig context
|
||||
stateFile = filepath.Join(townRoot, "witness", "state.json")
|
||||
case RoleCrew:
|
||||
// Crew state: <townRoot>/<rig>/crew/<name>/state.json
|
||||
if crewID := getCrewIdentity(); crewID != "" {
|
||||
// crewID format: <rig>-crew-<name>
|
||||
parts := strings.SplitN(crewID, "-crew-", 2)
|
||||
if len(parts) == 2 {
|
||||
stateFile = filepath.Join(townRoot, parts[0], "crew", parts[1], "state.json")
|
||||
}
|
||||
}
|
||||
if stateFile == "" {
|
||||
// Fallback to cwd
|
||||
cwd, _ := os.Getwd()
|
||||
stateFile = filepath.Join(cwd, "state.json")
|
||||
}
|
||||
default:
|
||||
// For other roles, use a generic location
|
||||
stateFile = filepath.Join(townRoot, ".runtime", "agent-state.json")
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read existing state or create new
|
||||
state := make(map[string]interface{})
|
||||
if data, err := os.ReadFile(stateFile); err == nil {
|
||||
_ = json.Unmarshal(data, &state)
|
||||
}
|
||||
|
||||
// Set requesting state
|
||||
state["requesting_"+string(action)] = true
|
||||
state["requesting_time"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
// Write back
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
// getSessionPane returns the pane identifier for a session's main pane.
|
||||
func getSessionPane(sessionName string) (string, error) {
|
||||
// Get the pane ID for the first pane in the session
|
||||
out, err := exec.Command("tmux", "list-panes", "-t", sessionName, "-F", "#{pane_id}").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return os.WriteFile(stateFile, data, 0644)
|
||||
}
|
||||
|
||||
// signalDaemon sends SIGUSR1 to the daemon to trigger immediate lifecycle processing.
|
||||
func signalDaemon(townRoot string) error {
|
||||
pidFile := filepath.Join(townRoot, "daemon", "daemon.pid")
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading daemon PID: %w", err)
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing daemon PID: %w", err)
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding daemon process: %w", err)
|
||||
}
|
||||
|
||||
if err := process.Signal(syscall.SIGUSR1); err != nil {
|
||||
return fmt.Errorf("signaling daemon: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) == 0 || lines[0] == "" {
|
||||
return "", fmt.Errorf("no panes found in session")
|
||||
}
|
||||
return lines[0], nil
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
var recycleCmd = &cobra.Command{
|
||||
Use: "recycle [role]",
|
||||
Short: "Hot-reload the current (or specified) agent session",
|
||||
Long: `Instantly restart an agent session in place.
|
||||
|
||||
This command uses tmux respawn-pane to kill the current session and restart it
|
||||
with a fresh Claude instance, running the full startup/priming sequence.
|
||||
|
||||
When run without arguments, recycles the current session.
|
||||
When given a role name, recycles that role's session (and switches to it).
|
||||
|
||||
Examples:
|
||||
gt recycle # Recycle current session
|
||||
gt recycle crew # Recycle crew session (auto-detect name)
|
||||
gt recycle mayor # Recycle mayor session
|
||||
gt recycle witness # Recycle witness session for current rig
|
||||
|
||||
The command executes instantly - no handoff, no manager involved.
|
||||
Use 'gt handoff' for graceful lifecycle transitions with context preservation.`,
|
||||
RunE: runRecycle,
|
||||
}
|
||||
|
||||
var (
|
||||
recycleWatch bool
|
||||
recycleDryRun bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
recycleCmd.Flags().BoolVarP(&recycleWatch, "watch", "w", true, "Switch to recycled session (for remote recycle)")
|
||||
recycleCmd.Flags().BoolVarP(&recycleDryRun, "dry-run", "n", false, "Show what would be done without executing")
|
||||
rootCmd.AddCommand(recycleCmd)
|
||||
}
|
||||
|
||||
func runRecycle(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Verify we're in tmux
|
||||
if !tmux.IsInsideTmux() {
|
||||
return fmt.Errorf("not running in tmux - cannot recycle")
|
||||
}
|
||||
|
||||
pane := os.Getenv("TMUX_PANE")
|
||||
if pane == "" {
|
||||
return fmt.Errorf("TMUX_PANE not set - cannot recycle")
|
||||
}
|
||||
|
||||
// Get current session name
|
||||
currentSession, err := getCurrentTmuxSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting session name: %w", err)
|
||||
}
|
||||
|
||||
// Determine target session
|
||||
targetSession := currentSession
|
||||
if len(args) > 0 {
|
||||
// User specified a role to recycle
|
||||
targetSession, err = resolveRoleToSession(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the restart command
|
||||
restartCmd, err := buildRestartCommand(targetSession)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If recycling a different session, we need to find its pane and respawn there
|
||||
if targetSession != currentSession {
|
||||
return recycleRemoteSession(t, targetSession, restartCmd)
|
||||
}
|
||||
|
||||
// Recycling ourselves - print feedback then respawn
|
||||
fmt.Printf("%s Recycling %s...\n", style.Bold.Render("♻️"), currentSession)
|
||||
|
||||
// Dry run mode - show what would happen
|
||||
if recycleDryRun {
|
||||
fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", pane, restartCmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use exec to respawn the pane - this kills us and restarts
|
||||
return t.RespawnPane(pane, restartCmd)
|
||||
}
|
||||
|
||||
// getCurrentTmuxSession returns the current tmux session name.
|
||||
func getCurrentTmuxSession() (string, error) {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// resolveRoleToSession converts a role name to a tmux session name.
|
||||
// For roles that need context (crew, witness, refinery), it auto-detects from environment.
|
||||
func resolveRoleToSession(role string) (string, error) {
|
||||
switch strings.ToLower(role) {
|
||||
case "mayor", "may":
|
||||
return "gt-mayor", nil
|
||||
|
||||
case "deacon", "dea":
|
||||
return "gt-deacon", nil
|
||||
|
||||
case "crew":
|
||||
// Try to get rig and crew name from environment or cwd
|
||||
rig := os.Getenv("GT_RIG")
|
||||
crewName := os.Getenv("GT_CREW")
|
||||
if rig == "" || crewName == "" {
|
||||
// Try to detect from cwd
|
||||
detected, err := detectCrewFromCwd()
|
||||
if err == nil {
|
||||
rig = detected.rigName
|
||||
crewName = detected.crewName
|
||||
}
|
||||
}
|
||||
if rig == "" || crewName == "" {
|
||||
return "", fmt.Errorf("cannot determine crew identity - run from crew directory or specify GT_RIG/GT_CREW")
|
||||
}
|
||||
return fmt.Sprintf("gt-%s-crew-%s", rig, crewName), nil
|
||||
|
||||
case "witness", "wit":
|
||||
rig := os.Getenv("GT_RIG")
|
||||
if rig == "" {
|
||||
return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context")
|
||||
}
|
||||
return fmt.Sprintf("gt-%s-witness", rig), nil
|
||||
|
||||
case "refinery", "ref":
|
||||
rig := os.Getenv("GT_RIG")
|
||||
if rig == "" {
|
||||
return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context")
|
||||
}
|
||||
return fmt.Sprintf("gt-%s-refinery", rig), nil
|
||||
|
||||
default:
|
||||
// Assume it's a direct session name
|
||||
return role, nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildRestartCommand creates the gt command to restart a session.
|
||||
func buildRestartCommand(sessionName string) (string, error) {
|
||||
switch {
|
||||
case sessionName == "gt-mayor":
|
||||
return "gt may at", nil
|
||||
|
||||
case sessionName == "gt-deacon":
|
||||
return "gt dea at", nil
|
||||
|
||||
case strings.Contains(sessionName, "-crew-"):
|
||||
// gt-<rig>-crew-<name>
|
||||
// The attach command can auto-detect from cwd, so just use `gt crew at`
|
||||
return "gt crew at", nil
|
||||
|
||||
case strings.HasSuffix(sessionName, "-witness"):
|
||||
// gt-<rig>-witness
|
||||
return "gt wit at", nil
|
||||
|
||||
case strings.HasSuffix(sessionName, "-refinery"):
|
||||
// gt-<rig>-refinery
|
||||
return "gt ref at", nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown session type: %s (try specifying role explicitly)", sessionName)
|
||||
}
|
||||
}
|
||||
|
||||
// recycleRemoteSession respawns a different session and optionally switches to it.
|
||||
func recycleRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error {
|
||||
// Check if target session exists
|
||||
exists, err := t.HasSession(targetSession)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("session '%s' not found - is the agent running?", targetSession)
|
||||
}
|
||||
|
||||
// Get the pane ID for the target session
|
||||
targetPane, err := getSessionPane(targetSession)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting target pane: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Recycling %s...\n", style.Bold.Render("♻️"), targetSession)
|
||||
|
||||
// Dry run mode
|
||||
if recycleDryRun {
|
||||
fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", targetPane, restartCmd)
|
||||
if recycleWatch {
|
||||
fmt.Printf("Would execute: tmux switch-client -t %s\n", targetSession)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Respawn the remote session's pane
|
||||
if err := t.RespawnPane(targetPane, restartCmd); err != nil {
|
||||
return fmt.Errorf("respawning pane: %w", err)
|
||||
}
|
||||
|
||||
// If --watch, switch to that session
|
||||
if recycleWatch {
|
||||
fmt.Printf("Switching to %s...\n", targetSession)
|
||||
// Use tmux switch-client to move our view to the target session
|
||||
if err := exec.Command("tmux", "switch-client", "-t", targetSession).Run(); err != nil {
|
||||
// Non-fatal - they can manually switch
|
||||
fmt.Printf("Note: Could not auto-switch (use: tmux switch-client -t %s)\n", targetSession)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSessionPane returns the pane identifier for a session's main pane.
|
||||
func getSessionPane(sessionName string) (string, error) {
|
||||
// Get the pane ID for the first pane in the session
|
||||
out, err := exec.Command("tmux", "list-panes", "-t", sessionName, "-F", "#{pane_id}").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) == 0 || lines[0] == "" {
|
||||
return "", fmt.Errorf("no panes found in session")
|
||||
}
|
||||
return lines[0], nil
|
||||
}
|
||||
@@ -38,7 +38,10 @@ var sessionCmd = &cobra.Command{
|
||||
Long: `Manage tmux sessions for polecats.
|
||||
|
||||
Sessions are tmux sessions running Claude for each polecat.
|
||||
Use the subcommands to start, stop, attach, and monitor sessions.`,
|
||||
Use the subcommands to start, stop, attach, and monitor sessions.
|
||||
|
||||
TIP: To send messages to a running session, use 'gt nudge' (not 'session inject').
|
||||
The nudge command uses reliable delivery that works correctly with Claude Code.`,
|
||||
}
|
||||
|
||||
var sessionStartCmd = &cobra.Command{
|
||||
@@ -104,14 +107,19 @@ Examples:
|
||||
|
||||
var sessionInjectCmd = &cobra.Command{
|
||||
Use: "inject <rig>/<polecat>",
|
||||
Short: "Send message to session",
|
||||
Short: "Send message to session (prefer 'gt nudge')",
|
||||
Long: `Send a message to a polecat session.
|
||||
|
||||
Injects text into the session via tmux send-keys. Useful for nudges or notifications.
|
||||
NOTE: For sending messages to Claude sessions, use 'gt nudge' instead.
|
||||
It uses reliable delivery (literal mode + timing) that works correctly
|
||||
with Claude Code's input handling.
|
||||
|
||||
This command is a low-level primitive for file-based injection or
|
||||
cases where you need raw tmux send-keys behavior.
|
||||
|
||||
Examples:
|
||||
gt session inject wyvern/Toast -m "Check your mail"
|
||||
gt session inject wyvern/Toast -f prompt.txt`,
|
||||
gt nudge gastown/furiosa "Check your mail" # Preferred
|
||||
gt session inject wyvern/Toast -f prompt.txt # For file injection`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSessionInject,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user