package cmd import ( "encoding/json" "fmt" "os" "os/exec" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) var slingCmd = &cobra.Command{ Use: "sling [target]", GroupID: GroupWork, Short: "Assign work to an agent (THE unified work dispatch command)", Long: `Sling work onto an agent's hook and start working immediately. This is THE command for assigning work in Gas Town. It handles: - Existing agents (mayor, crew, witness, refinery) - Auto-spawning polecats when target is a rig - Formula instantiation and wisp creation - No-tmux mode for manual agent operation Target Resolution: gt sling gt-abc # Self (current agent) gt sling gt-abc crew # Crew worker in current rig gt sling gt-abc gastown # Auto-spawn polecat in rig gt sling gt-abc gastown/Toast # Specific polecat gt sling gt-abc mayor # Mayor Spawning Options (when target is a rig): gt sling gt-abc gastown --molecule mol-review # Use specific workflow gt sling gt-abc gastown --create # Create polecat if missing gt sling gt-abc gastown --naked # No-tmux (manual start) gt sling gt-abc gastown --force # Ignore unread mail gt sling gt-abc gastown --account work # Use specific Claude account Natural Language Args: gt sling gt-abc --args "patch release" gt sling code-review --args "focus on security" The --args string is stored in the bead and shown via gt prime. Since the executor is an LLM, it interprets these instructions naturally. Formula Slinging: gt sling mol-release mayor/ # Cook + wisp + attach + nudge gt sling towers-of-hanoi --var disks=3 Formula-on-Bead (--on flag): gt sling mol-review --on gt-abc # Apply formula to existing work gt sling shiny --on gt-abc crew # Apply formula, sling to crew Compare: gt hook # Just attach (no action) gt sling # Attach + start now (keep context) gt handoff # Attach + restart (fresh context) The propulsion principle: if it's on your hook, YOU RUN IT.`, Args: cobra.RangeArgs(1, 2), RunE: runSling, } var ( slingSubject string slingMessage string slingDryRun bool slingOnTarget string // --on flag: target bead when slinging a formula slingVars []string // --var flag: formula variables (key=value) slingArgs string // --args flag: natural language instructions for executor // Flags migrated for polecat spawning (used by sling for work assignment slingNaked bool // --naked: no-tmux mode (skip session creation) slingCreate bool // --create: create polecat if it doesn't exist slingMolecule string // --molecule: workflow to instantiate on the bead slingForce bool // --force: force spawn even if polecat has unread mail slingAccount string // --account: Claude Code account handle to use ) func init() { slingCmd.Flags().StringVarP(&slingSubject, "subject", "s", "", "Context subject for the work") slingCmd.Flags().StringVarP(&slingMessage, "message", "m", "", "Context message for the work") slingCmd.Flags().BoolVarP(&slingDryRun, "dry-run", "n", false, "Show what would be done") slingCmd.Flags().StringVar(&slingOnTarget, "on", "", "Apply formula to existing bead (implies wisp scaffolding)") slingCmd.Flags().StringArrayVar(&slingVars, "var", nil, "Formula variable (key=value), can be repeated") slingCmd.Flags().StringVarP(&slingArgs, "args", "a", "", "Natural language instructions for the executor (e.g., 'patch release')") // Flags for polecat spawning (when target is a rig) slingCmd.Flags().BoolVar(&slingNaked, "naked", false, "No-tmux mode: assign work but skip session creation (manual start)") slingCmd.Flags().BoolVar(&slingCreate, "create", false, "Create polecat if it doesn't exist") slingCmd.Flags().StringVar(&slingMolecule, "molecule", "", "Molecule workflow to instantiate on the bead") slingCmd.Flags().BoolVar(&slingForce, "force", false, "Force spawn even if polecat has unread mail") slingCmd.Flags().StringVar(&slingAccount, "account", "", "Claude Code account handle to use") rootCmd.AddCommand(slingCmd) } func runSling(cmd *cobra.Command, args []string) error { // Polecats cannot sling - check early before writing anything if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" { return fmt.Errorf("polecats cannot sling (use gt done for handoff)") } // --var is only for standalone formula mode, not formula-on-bead mode if slingOnTarget != "" && len(slingVars) > 0 { return fmt.Errorf("--var cannot be used with --on (formula-on-bead mode doesn't support variables)") } // Determine mode based on flags and argument types var beadID string var formulaName string if slingOnTarget != "" { // Formula-on-bead mode: gt sling --on formulaName = args[0] beadID = slingOnTarget // Verify both exist if err := verifyBeadExists(beadID); err != nil { return err } if err := verifyFormulaExists(formulaName); err != nil { return err } } else { // Could be bead mode or standalone formula mode firstArg := args[0] // Try as bead first if err := verifyBeadExists(firstArg); err == nil { // It's a bead beadID = firstArg } else { // Not a bead - try as standalone formula if err := verifyFormulaExists(firstArg); err == nil { // Standalone formula mode: gt sling [target] return runSlingFormula(args) } // Neither bead nor formula return fmt.Errorf("'%s' is not a valid bead or formula", firstArg) } } // Determine target agent (self or specified) var targetAgent string var targetPane string var err error if len(args) > 1 { target := args[1] // Check if target is a rig name (auto-spawn polecat) if rigName, isRig := IsRigName(target); isRig { if slingDryRun { // Dry run - just indicate what would happen fmt.Printf("Would spawn fresh polecat in rig '%s'\n", rigName) if slingNaked { fmt.Printf(" --naked: would skip tmux session\n") } targetAgent = fmt.Sprintf("%s/polecats/", rigName) targetPane = "" } else { // Spawn a fresh polecat in the rig fmt.Printf("Target is rig '%s', spawning fresh polecat...\n", rigName) spawnOpts := SlingSpawnOptions{ Force: slingForce, Naked: slingNaked, Account: slingAccount, Create: slingCreate, } spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts) if spawnErr != nil { return fmt.Errorf("spawning polecat: %w", spawnErr) } targetAgent = spawnInfo.AgentID() targetPane = spawnInfo.Pane // Wake witness and refinery to monitor the new polecat wakeRigAgents(rigName) } } else { // Slinging to an existing agent targetAgent, targetPane, _, err = resolveTargetAgent(target) if err != nil { return fmt.Errorf("resolving target: %w", err) } } } else { // Slinging to self targetAgent, targetPane, _, err = resolveSelfTarget() if err != nil { return err } } // Display what we're doing if formulaName != "" { fmt.Printf("%s Slinging formula %s on %s to %s...\n", style.Bold.Render("🎯"), formulaName, beadID, targetAgent) } else { fmt.Printf("%s Slinging %s to %s...\n", style.Bold.Render("🎯"), beadID, targetAgent) } // Check if bead is already pinned (guard against accidental re-sling) info, err := getBeadInfo(beadID) if err != nil { return fmt.Errorf("checking bead status: %w", err) } if info.Status == "pinned" && !slingForce { assignee := info.Assignee if assignee == "" { assignee = "(unknown)" } return fmt.Errorf("bead %s is already pinned to %s\nUse --force to re-sling", beadID, assignee) } if slingDryRun { fmt.Printf("Would run: bd update %s --status=hooked --assignee=%s\n", beadID, targetAgent) if formulaName != "" { fmt.Printf(" formula: %s\n", formulaName) } if slingSubject != "" { fmt.Printf(" subject (in nudge): %s\n", slingSubject) } if slingMessage != "" { fmt.Printf(" context: %s\n", slingMessage) } if slingArgs != "" { fmt.Printf(" args (in nudge): %s\n", slingArgs) } fmt.Printf("Would inject start prompt to pane: %s\n", targetPane) return nil } // Hook the bead using bd update (discovery-based approach) hookCmd := exec.Command("bd", "update", beadID, "--status=hooked", "--assignee="+targetAgent) hookCmd.Stderr = os.Stderr if err := hookCmd.Run(); err != nil { return fmt.Errorf("hooking bead: %w", err) } fmt.Printf("%s Work attached to hook (status=hooked)\n", style.Bold.Render("✓")) // Update agent bead's hook_bead field (ZFC: agents track their current work) updateAgentHookBead(targetAgent, beadID) // Store args in bead description (no-tmux mode: beads as data plane) if slingArgs != "" { if err := storeArgsInBead(beadID, slingArgs); err != nil { // Warn but don't fail - args will still be in the nudge prompt fmt.Printf("%s Could not store args in bead: %v\n", style.Dim.Render("Warning:"), err) } else { fmt.Printf("%s Args stored in bead (durable)\n", style.Bold.Render("✓")) } } // Try to inject the "start now" prompt (graceful if no tmux) if targetPane == "" { fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○")) } else if err := injectStartPrompt(targetPane, beadID, slingSubject, slingArgs); err != nil { // Graceful fallback for no-tmux mode fmt.Printf("%s Could not nudge (no tmux?): %v\n", style.Dim.Render("○"), err) fmt.Printf(" Agent will discover work via gt prime / bd show\n") } else { fmt.Printf("%s Start prompt sent\n", style.Bold.Render("▶")) } return nil } // storeArgsInBead stores args in the bead's description using attached_args field. // This enables no-tmux mode where agents discover args via gt prime / bd show. func storeArgsInBead(beadID, args string) error { // Get the bead to preserve existing description content showCmd := exec.Command("bd", "show", beadID, "--json") out, err := showCmd.Output() if err != nil { return fmt.Errorf("fetching bead: %w", err) } // Parse the bead var issues []beads.Issue if err := json.Unmarshal(out, &issues); err != nil { return fmt.Errorf("parsing bead: %w", err) } if len(issues) == 0 { return fmt.Errorf("bead not found") } issue := &issues[0] // Get or create attachment fields fields := beads.ParseAttachmentFields(issue) if fields == nil { fields = &beads.AttachmentFields{} } // Set the args fields.AttachedArgs = args // Update the description newDesc := beads.SetAttachmentFields(issue, fields) // Update the bead updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc) updateCmd.Stderr = os.Stderr if err := updateCmd.Run(); err != nil { return fmt.Errorf("updating bead description: %w", err) } return nil } // injectStartPrompt sends a prompt to the target pane to start working. // Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter. func injectStartPrompt(pane, beadID, subject, args string) error { if pane == "" { return fmt.Errorf("no target pane") } // Build the prompt to inject var prompt string if args != "" { // Args provided - include them prominently in the prompt if subject != "" { prompt = fmt.Sprintf("Work slung: %s (%s). Args: %s. Start working now - use these args to guide your execution.", beadID, subject, args) } else { prompt = fmt.Sprintf("Work slung: %s. Args: %s. Start working now - use these args to guide your execution.", beadID, args) } } else if subject != "" { prompt = fmt.Sprintf("Work slung: %s (%s). Start working on it now - no questions, just begin.", beadID, subject) } else { prompt = fmt.Sprintf("Work slung: %s. Start working on it now - run `gt mol status` to see the hook, then begin.", beadID) } // Use the reliable nudge pattern (same as gt nudge / tmux.NudgeSession) t := tmux.NewTmux() return t.NudgePane(pane, prompt) } // resolveTargetAgent converts a target spec to agent ID, pane, and hook root. func resolveTargetAgent(target string) (agentID string, pane string, hookRoot string, err error) { // First resolve to session name sessionName, err := resolveRoleToSession(target) if err != nil { return "", "", "", err } // Get the pane for that session pane, err = getSessionPane(sessionName) if err != nil { return "", "", "", fmt.Errorf("getting pane for %s: %w", sessionName, err) } // Get the target's working directory for hook storage t := tmux.NewTmux() hookRoot, err = t.GetPaneWorkDir(sessionName) if err != nil { return "", "", "", fmt.Errorf("getting working dir for %s: %w", sessionName, err) } // Convert session name back to agent ID format agentID = sessionToAgentID(sessionName) return agentID, pane, hookRoot, nil } // sessionToAgentID converts a session name to agent ID format. // Uses session.ParseSessionName for consistent parsing across the codebase. func sessionToAgentID(sessionName string) string { identity, err := session.ParseSessionName(sessionName) if err != nil { // Fallback for unparseable sessions return sessionName } return identity.Address() } // verifyBeadExists checks that the bead exists using bd show. func verifyBeadExists(beadID string) error { cmd := exec.Command("bd", "show", beadID, "--json") if err := cmd.Run(); err != nil { return fmt.Errorf("bead '%s' not found (bd show failed)", beadID) } return nil } // beadInfo holds status and assignee for a bead. type beadInfo struct { Status string `json:"status"` Assignee string `json:"assignee"` } // getBeadInfo returns status and assignee for a bead. func getBeadInfo(beadID string) (*beadInfo, error) { cmd := exec.Command("bd", "show", beadID, "--json") out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("bead '%s' not found", beadID) } // bd show --json returns an array (issue + dependents), take first element var infos []beadInfo if err := json.Unmarshal(out, &infos); err != nil { return nil, fmt.Errorf("parsing bead info: %w", err) } if len(infos) == 0 { return nil, fmt.Errorf("bead '%s' not found", beadID) } return &infos[0], nil } // detectCloneRoot finds the root of the current git clone. func detectCloneRoot() (string, error) { cmd := exec.Command("git", "rev-parse", "--show-toplevel") out, err := cmd.Output() if err != nil { return "", fmt.Errorf("not in a git repository") } return strings.TrimSpace(string(out)), nil } // resolveSelfTarget determines agent identity, pane, and hook root for slinging to self. func resolveSelfTarget() (agentID string, pane string, hookRoot string, err error) { roleInfo, err := GetRole() if err != nil { return "", "", "", fmt.Errorf("detecting role: %w", err) } // Build agent identity from role switch roleInfo.Role { case RoleMayor: agentID = "mayor" case RoleDeacon: agentID = "deacon" case RoleWitness: agentID = fmt.Sprintf("%s/witness", roleInfo.Rig) case RoleRefinery: agentID = fmt.Sprintf("%s/refinery", roleInfo.Rig) case RolePolecat: agentID = fmt.Sprintf("%s/polecats/%s", roleInfo.Rig, roleInfo.Polecat) case RoleCrew: agentID = fmt.Sprintf("%s/crew/%s", roleInfo.Rig, roleInfo.Polecat) default: return "", "", "", fmt.Errorf("cannot determine agent identity (role: %s)", roleInfo.Role) } pane = os.Getenv("TMUX_PANE") hookRoot = roleInfo.Home if hookRoot == "" { // Fallback to git root if home not determined hookRoot, err = detectCloneRoot() if err != nil { return "", "", "", fmt.Errorf("detecting clone root: %w", err) } } return agentID, pane, hookRoot, nil } // verifyFormulaExists checks that the formula exists using bd formula show. // Formulas are TOML files (.formula.toml). func verifyFormulaExists(formulaName string) error { // Try bd formula show (handles all formula file formats) cmd := exec.Command("bd", "formula", "show", formulaName) if err := cmd.Run(); err == nil { return nil } // Try with mol- prefix cmd = exec.Command("bd", "formula", "show", "mol-"+formulaName) if err := cmd.Run(); err == nil { return nil } return fmt.Errorf("formula '%s' not found (check 'bd formula list')", formulaName) } // runSlingFormula handles standalone formula slinging. // Flow: cook → wisp → attach to hook → nudge func runSlingFormula(args []string) error { formulaName := args[0] // Determine target (self or specified) var target string if len(args) > 1 { target = args[1] } // Resolve target agent and pane var targetAgent string var targetPane string var err error if target != "" { // Check if target is a rig name (auto-spawn polecat) if rigName, isRig := IsRigName(target); isRig { if slingDryRun { // Dry run - just indicate what would happen fmt.Printf("Would spawn fresh polecat in rig '%s'\n", rigName) if slingNaked { fmt.Printf(" --naked: would skip tmux session\n") } targetAgent = fmt.Sprintf("%s/polecats/", rigName) targetPane = "" } else { // Spawn a fresh polecat in the rig fmt.Printf("Target is rig '%s', spawning fresh polecat...\n", rigName) spawnOpts := SlingSpawnOptions{ Force: slingForce, Naked: slingNaked, Account: slingAccount, Create: slingCreate, } spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts) if spawnErr != nil { return fmt.Errorf("spawning polecat: %w", spawnErr) } targetAgent = spawnInfo.AgentID() targetPane = spawnInfo.Pane // Wake witness and refinery to monitor the new polecat wakeRigAgents(rigName) } } else { // Slinging to an existing agent targetAgent, targetPane, _, err = resolveTargetAgent(target) if err != nil { return fmt.Errorf("resolving target: %w", err) } } } else { // Slinging to self targetAgent, targetPane, _, err = resolveSelfTarget() if err != nil { return err } } fmt.Printf("%s Slinging formula %s to %s...\n", style.Bold.Render("🎯"), formulaName, targetAgent) if slingDryRun { fmt.Printf("Would cook formula: %s\n", formulaName) fmt.Printf("Would create wisp and pin to: %s\n", targetAgent) for _, v := range slingVars { fmt.Printf(" --var %s\n", v) } fmt.Printf("Would nudge pane: %s\n", targetPane) return nil } // Step 1: Cook the formula (ensures proto exists) fmt.Printf(" Cooking formula...\n") cookArgs := []string{"cook", formulaName} cookCmd := exec.Command("bd", cookArgs...) cookCmd.Stderr = os.Stderr if err := cookCmd.Run(); err != nil { return fmt.Errorf("cooking formula: %w", err) } // Step 2: Create wisp instance (ephemeral) fmt.Printf(" Creating wisp...\n") wispArgs := []string{"wisp", formulaName} for _, v := range slingVars { wispArgs = append(wispArgs, "--var", v) } wispArgs = append(wispArgs, "--json") wispCmd := exec.Command("bd", wispArgs...) wispCmd.Stderr = os.Stderr // Show wisp errors to user wispOut, err := wispCmd.Output() if err != nil { return fmt.Errorf("creating wisp: %w", err) } // Parse wisp output to get the root ID var wispResult struct { RootID string `json:"root_id"` } if err := json.Unmarshal(wispOut, &wispResult); err != nil { // Fallback: use formula name as identifier, but warn user fmt.Printf("%s Could not parse wisp output, using formula name as ID\n", style.Dim.Render("Warning:")) wispResult.RootID = formulaName } fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispResult.RootID) // Step 3: Hook the wisp bead using bd update (discovery-based approach) hookCmd := exec.Command("bd", "update", wispResult.RootID, "--status=hooked", "--assignee="+targetAgent) hookCmd.Stderr = os.Stderr if err := hookCmd.Run(); err != nil { return fmt.Errorf("hooking wisp bead: %w", err) } fmt.Printf("%s Attached to hook (status=hooked)\n", style.Bold.Render("✓")) // Update agent bead's hook_bead field (ZFC: agents track their current work) updateAgentHookBead(targetAgent, wispResult.RootID) // Store args in wisp bead if provided (no-tmux mode: beads as data plane) if slingArgs != "" { if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil { fmt.Printf("%s Could not store args in bead: %v\n", style.Dim.Render("Warning:"), err) } else { fmt.Printf("%s Args stored in bead (durable)\n", style.Bold.Render("✓")) } } // Step 4: Nudge to start (graceful if no tmux) if targetPane == "" { fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○")) return nil } var prompt string if slingArgs != "" { prompt = fmt.Sprintf("Formula %s slung. Args: %s. Run `gt mol status` to see your hook, then execute using these args.", formulaName, slingArgs) } else { prompt = fmt.Sprintf("Formula %s slung. Run `gt mol status` to see your hook, then execute the steps.", formulaName) } t := tmux.NewTmux() if err := t.NudgePane(targetPane, prompt); err != nil { // Graceful fallback for no-tmux mode fmt.Printf("%s Could not nudge (no tmux?): %v\n", style.Dim.Render("○"), err) fmt.Printf(" Agent will discover work via gt prime / bd show\n") } else { fmt.Printf("%s Nudged to start\n", style.Bold.Render("▶")) } return nil } // updateAgentHookBead updates the agent bead's hook_bead field when work is slung. // This enables the witness to see what each agent is working on. func updateAgentHookBead(agentID, beadID string) { // Convert agent ID to agent bead ID // Format examples (canonical: prefix-rig-role-name): // gastown/crew/max -> gt-gastown-crew-max // gastown/polecats/Toast -> gt-gastown-polecat-Toast // mayor -> gt-mayor // gastown/witness -> gt-gastown-witness agentBeadID := agentIDToBeadID(agentID) if agentBeadID == "" { return } // Find beads directory - try current directory first workDir, err := os.Getwd() if err != nil { return } bd := beads.New(workDir) if err := bd.UpdateAgentState(agentBeadID, "running", &beadID); err != nil { // Silently ignore - agent bead might not exist yet return } } // wakeRigAgents wakes the witness and refinery for a rig after polecat dispatch. // This ensures the patrol agents are ready to monitor and merge. func wakeRigAgents(rigName string) { // Boot the rig (idempotent - no-op if already running) bootCmd := exec.Command("gt", "rig", "boot", rigName) _ = bootCmd.Run() // Ignore errors - rig might already be running // Nudge witness and refinery to clear any backoff t := tmux.NewTmux() witnessSession := fmt.Sprintf("gt-%s-witness", rigName) refinerySession := fmt.Sprintf("gt-%s-refinery", rigName) // Silent nudges - sessions might not exist yet _ = t.NudgeSession(witnessSession, "Polecat dispatched - check for work") _ = t.NudgeSession(refinerySession, "Polecat dispatched - check for merge requests") } // agentIDToBeadID converts an agent ID to its corresponding agent bead ID. // Uses canonical naming: prefix-rig-role-name func agentIDToBeadID(agentID string) string { // Handle simple cases if agentID == "mayor" { return beads.MayorBeadID() } if agentID == "deacon" { return beads.DeaconBeadID() } // Parse path-style agent IDs parts := strings.Split(agentID, "/") if len(parts) < 2 { return "" } rig := parts[0] switch { case len(parts) == 2 && parts[1] == "witness": return beads.WitnessBeadID(rig) case len(parts) == 2 && parts[1] == "refinery": return beads.RefineryBeadID(rig) case len(parts) == 3 && parts[1] == "crew": return beads.CrewBeadID(rig, parts[2]) case len(parts) == 3 && parts[1] == "polecats": return beads.PolecatBeadID(rig, parts[2]) default: return "" } }