Files
gastown/internal/cmd/unsling.go
gastown/crew/jack 30a6f27404 fix(sling): remove obsolete --naked flag
The --naked flag (skip tmux session creation) was a vestige of an earlier
design requiring manual session management. With the current polecat
architecture where polecats are witness-managed, ephemeral, and self-deleting
after task completion, manual session management is no longer needed.

The flag also created invalid states (e.g., slinging to crew --naked left
them unreachable since crew require tmux sessions for communication).

Closes gt-xhn5s

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

196 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var unslingCmd = &cobra.Command{
Use: "unsling [bead-id] [target]",
Aliases: []string{"unhook"},
GroupID: GroupWork,
Short: "Remove work from an agent's hook",
Long: `Remove work from an agent's hook (the inverse of sling/hook).
With no arguments, clears your own hook. With a bead ID, only unslings
if that specific bead is currently hooked. With a target, operates on
another agent's hook.
Examples:
gt unsling # Clear my hook (whatever's there)
gt unsling gt-abc # Only unsling if gt-abc is hooked
gt unsling greenplace/joe # Clear joe's hook
gt unsling gt-abc greenplace/joe # Unsling gt-abc from joe
The bead's status changes from 'hooked' back to 'open'.
Related commands:
gt sling <bead> # Hook + start (inverse of unsling)
gt hook <bead> # Hook without starting
gt hook # See what's on your hook`,
Args: cobra.MaximumNArgs(2),
RunE: runUnsling,
}
var (
unslingDryRun bool
unslingForce bool
)
func init() {
unslingCmd.Flags().BoolVarP(&unslingDryRun, "dry-run", "n", false, "Show what would be done")
unslingCmd.Flags().BoolVarP(&unslingForce, "force", "f", false, "Unsling even if work is incomplete")
rootCmd.AddCommand(unslingCmd)
}
func runUnsling(cmd *cobra.Command, args []string) error {
var targetBeadID string
var targetAgent string
// Parse args: [bead-id] [target]
switch len(args) {
case 0:
// No args - unsling self, whatever is hooked
case 1:
// Could be bead ID or target agent
// If it contains "/" or is a known role, treat as target
if isAgentTarget(args[0]) {
targetAgent = args[0]
} else {
targetBeadID = args[0]
}
case 2:
targetBeadID = args[0]
targetAgent = args[1]
}
// Resolve target agent (default: self)
var agentID string
var err error
if targetAgent != "" {
agentID, _, _, err = resolveTargetAgent(targetAgent)
if err != nil {
return fmt.Errorf("resolving target agent: %w", err)
}
} else {
agentID, _, _, err = resolveSelfTarget()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
}
// Find town root and rig path for agent beads
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Extract rig name from agent ID (e.g., "gastown/crew/joe" -> "gastown")
// For town-level agents like "mayor/", use town root
rigName := strings.Split(agentID, "/")[0]
var beadsPath string
if rigName == "mayor" || rigName == "deacon" {
beadsPath = townRoot
} else {
beadsPath = filepath.Join(townRoot, rigName)
}
b := beads.New(beadsPath)
// Convert agent ID to agent bead ID and look up the agent bead
agentBeadID := agentIDToBeadID(agentID, townRoot)
if agentBeadID == "" {
return fmt.Errorf("could not convert agent ID %s to bead ID", agentID)
}
// Get the agent bead to find current hook
agentBead, err := b.Show(agentBeadID)
if err != nil {
return fmt.Errorf("getting agent bead %s: %w", agentBeadID, err)
}
// Check if agent has work hooked (via hook_bead field)
hookedBeadID := agentBead.HookBead
if hookedBeadID == "" {
if targetAgent != "" {
fmt.Printf("%s No work hooked for %s\n", style.Dim.Render(""), agentID)
} else {
fmt.Printf("%s Nothing on your hook\n", style.Dim.Render(""))
}
return nil
}
// If specific bead requested, verify it matches
if targetBeadID != "" && hookedBeadID != targetBeadID {
return fmt.Errorf("bead %s is not hooked (current hook: %s)", targetBeadID, hookedBeadID)
}
// Get the hooked bead to check completion and show title
hookedBead, err := b.Show(hookedBeadID)
if err != nil {
// Bead might be deleted - still allow unsling with --force
if !unslingForce {
return fmt.Errorf("getting hooked bead %s: %w\n Use --force to unsling anyway", hookedBeadID, err)
}
// Force mode - proceed without the bead details
hookedBead = &beads.Issue{ID: hookedBeadID, Title: "(unknown)"}
}
// Check if work is complete (warn if not, unless --force)
isComplete := hookedBead.Status == "closed"
if !isComplete && !unslingForce {
return fmt.Errorf("hooked work %s is incomplete (%s)\n Use --force to unsling anyway",
hookedBeadID, hookedBead.Title)
}
if targetAgent != "" {
fmt.Printf("%s Unslinging %s from %s...\n", style.Bold.Render("🪝"), hookedBeadID, agentID)
} else {
fmt.Printf("%s Unslinging %s...\n", style.Bold.Render("🪝"), hookedBeadID)
}
if unslingDryRun {
fmt.Printf("Would clear hook_bead from agent bead %s\n", agentBeadID)
return nil
}
// Clear the hook (gt-zecmc: removed agent_state update - observable from tmux)
if err := b.ClearHookBead(agentBeadID); err != nil {
return fmt.Errorf("clearing hook from agent bead %s: %w", agentBeadID, err)
}
// Log unhook event
_ = events.LogFeed(events.TypeUnhook, agentID, events.UnhookPayload(hookedBeadID))
fmt.Printf("%s Work removed from hook\n", style.Bold.Render("✓"))
fmt.Printf(" Agent %s hook cleared (was: %s)\n", agentID, hookedBeadID)
return nil
}
// isAgentTarget checks if a string looks like an agent target rather than a bead ID.
// Agent targets contain "/" or are known role names.
func isAgentTarget(s string) bool {
// Contains "/" means it's a path like "greenplace/joe"
for _, c := range s {
if c == '/' {
return true
}
}
// Known role names
switch s {
case "mayor", "deacon", "witness", "refinery", "crew":
return true
}
return false
}