Files
gastown/internal/cmd/dog.go
riker 2590e3de3b fix(dog): spawn session and set BD_ACTOR for dog dispatch
Recovered from reflog - these commits were lost during a rebase/force-push.

Dogs are directories with state files but no sessions. When `gt dog dispatch`
assigned work and sent mail, nothing executed because no session existed.

Changes:
1. Spawn tmux session after dispatch (gt-<town>-deacon-<dogname>)
2. Set BD_ACTOR=deacon/dogs/<name> so dogs can find their mail
3. Add dog case to AgentEnv for proper identity

Session spawn is non-blocking - if it fails, mail was sent and human can
manually start the session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:15:29 -08:00

942 lines
24 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/dog"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/plugin"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
// Dog command flags
var (
dogListJSON bool
dogStatusJSON bool
dogForce bool
dogRemoveAll bool
dogCallAll bool
// Dispatch flags
dogDispatchPlugin string
dogDispatchRig string
dogDispatchCreate bool
dogDispatchDog string
dogDispatchJSON bool
dogDispatchDryRun bool
)
var dogCmd = &cobra.Command{
Use: "dog",
Aliases: []string{"dogs"},
GroupID: GroupAgents,
Short: "Manage dogs (cross-rig infrastructure workers)",
Long: `Manage dogs - reusable workers for infrastructure and cleanup.
CATS VS DOGS:
Polecats (cats) build features. One rig. Ephemeral (one task, then nuked).
Dogs clean up messes. Cross-rig. Reusable (multiple tasks, eventually recycled).
Dogs are managed by the Deacon for town-level work:
- Infrastructure tasks (rebuilding, syncing, migrations)
- Cleanup operations (orphan branches, stale files)
- Cross-rig work that spans multiple projects
Each dog has worktrees into every configured rig, enabling cross-project
operations. Dogs return to idle state after completing work (unlike cats).
The kennel is at ~/gt/deacon/dogs/. The Deacon dispatches work to dogs.`,
}
var dogAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Create a new dog in the kennel",
Long: `Create a new dog in the kennel with multi-rig worktrees.
Each dog gets a worktree per configured rig (e.g., gastown, beads).
The dog starts in idle state, ready to receive work from the Deacon.
Example:
gt dog add alpha
gt dog add bravo`,
Args: cobra.ExactArgs(1),
RunE: runDogAdd,
}
var dogRemoveCmd = &cobra.Command{
Use: "remove <name>... | --all",
Short: "Remove dogs from the kennel",
Long: `Remove one or more dogs from the kennel.
Removes all worktrees and the dog directory.
Use --force to remove even if dog is in working state.
Examples:
gt dog remove alpha
gt dog remove alpha bravo
gt dog remove --all
gt dog remove alpha --force`,
Args: func(cmd *cobra.Command, args []string) error {
if dogRemoveAll {
return nil
}
if len(args) < 1 {
return fmt.Errorf("requires at least 1 dog name (or use --all)")
}
return nil
},
RunE: runDogRemove,
}
var dogListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List all dogs in the kennel",
Long: `List all dogs in the kennel with their status.
Shows each dog's state (idle/working), current work assignment,
and last active timestamp.
Examples:
gt dog list
gt dog list --json`,
RunE: runDogList,
}
var dogCallCmd = &cobra.Command{
Use: "call [name]",
Short: "Wake idle dog(s) for work",
Long: `Wake an idle dog to prepare for work.
With a name, wakes the specific dog.
With --all, wakes all idle dogs.
Without arguments, wakes one idle dog (if available).
This updates the dog's last-active timestamp and can trigger
session creation for the dog's worktrees.
Examples:
gt dog call alpha
gt dog call --all
gt dog call`,
RunE: runDogCall,
}
var dogStatusCmd = &cobra.Command{
Use: "status [name]",
Short: "Show detailed dog status",
Long: `Show detailed status for a specific dog or summary for all dogs.
With a name, shows detailed info including:
- State (idle/working)
- Current work assignment
- Worktree paths per rig
- Last active timestamp
Without a name, shows pack summary:
- Total dogs
- Idle/working counts
- Pack health
Examples:
gt dog status alpha
gt dog status
gt dog status --json`,
RunE: runDogStatus,
}
var dogDispatchCmd = &cobra.Command{
Use: "dispatch --plugin <name>",
Short: "Dispatch plugin execution to a dog",
Long: `Dispatch a plugin for execution by a dog worker.
This is the formalized command for sending plugin work to dogs. The Deacon
uses this during patrol cycles to dispatch plugins with open gates.
The command:
1. Finds the plugin definition (plugin.md)
2. Assigns work to an idle dog (marks as working)
3. Sends mail with plugin instructions to the dog
4. Returns immediately (non-blocking)
The dog discovers the work via its mail inbox and executes the plugin
instructions. On completion, the dog sends DOG_DONE mail to deacon/.
Examples:
gt dog dispatch --plugin rebuild-gt
gt dog dispatch --plugin rebuild-gt --rig gastown
gt dog dispatch --plugin rebuild-gt --dog alpha
gt dog dispatch --plugin rebuild-gt --create
gt dog dispatch --plugin rebuild-gt --dry-run
gt dog dispatch --plugin rebuild-gt --json`,
RunE: runDogDispatch,
}
var dogDoneCmd = &cobra.Command{
Use: "done [name]",
Short: "Mark a dog as idle (work complete)",
Long: `Mark a dog as idle after completing its work.
Dogs call this command after finishing plugin execution to reset their state
to idle, allowing them to receive new work dispatches.
If no name is provided, attempts to detect the current dog from BD_ACTOR.
Examples:
gt dog done alpha # Explicit dog name
gt dog done # Auto-detect from BD_ACTOR (e.g., "deacon/dogs/alpha")`,
RunE: runDogDone,
}
func init() {
// List flags
dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON")
// Remove flags
dogRemoveCmd.Flags().BoolVarP(&dogForce, "force", "f", false, "Force removal even if working")
dogRemoveCmd.Flags().BoolVar(&dogRemoveAll, "all", false, "Remove all dogs")
// Call flags
dogCallCmd.Flags().BoolVar(&dogCallAll, "all", false, "Wake all idle dogs")
// Status flags
dogStatusCmd.Flags().BoolVar(&dogStatusJSON, "json", false, "Output as JSON")
// Dispatch flags
dogDispatchCmd.Flags().StringVar(&dogDispatchPlugin, "plugin", "", "Plugin name to dispatch (required)")
dogDispatchCmd.Flags().StringVar(&dogDispatchRig, "rig", "", "Limit plugin search to specific rig")
dogDispatchCmd.Flags().StringVar(&dogDispatchDog, "dog", "", "Dispatch to specific dog (default: any idle)")
dogDispatchCmd.Flags().BoolVar(&dogDispatchCreate, "create", false, "Create a dog if none idle")
dogDispatchCmd.Flags().BoolVar(&dogDispatchJSON, "json", false, "Output as JSON")
dogDispatchCmd.Flags().BoolVarP(&dogDispatchDryRun, "dry-run", "n", false, "Show what would be done without doing it")
_ = dogDispatchCmd.MarkFlagRequired("plugin")
// Add subcommands
dogCmd.AddCommand(dogAddCmd)
dogCmd.AddCommand(dogRemoveCmd)
dogCmd.AddCommand(dogListCmd)
dogCmd.AddCommand(dogCallCmd)
dogCmd.AddCommand(dogStatusCmd)
dogCmd.AddCommand(dogDispatchCmd)
dogCmd.AddCommand(dogDoneCmd)
rootCmd.AddCommand(dogCmd)
}
// getDogManager creates a dog.Manager with the current town root.
func getDogManager() (*dog.Manager, error) {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return nil, fmt.Errorf("finding town root: %w", err)
}
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
return nil, fmt.Errorf("loading rigs config: %w", err)
}
return dog.NewManager(townRoot, rigsConfig), nil
}
func runDogAdd(cmd *cobra.Command, args []string) error {
name := args[0]
// Validate name
if strings.ContainsAny(name, "/\\. ") {
return fmt.Errorf("dog name cannot contain /, \\, ., or spaces")
}
mgr, err := getDogManager()
if err != nil {
return err
}
d, err := mgr.Add(name)
if err != nil {
return fmt.Errorf("adding dog %s: %w", name, err)
}
fmt.Printf("✓ Created dog %s in kennel\n", style.Bold.Render(name))
fmt.Printf(" Path: %s\n", d.Path)
fmt.Printf(" Worktrees:\n")
for rigName, path := range d.Worktrees {
fmt.Printf(" %s: %s\n", rigName, path)
}
// Create agent bead for the dog
townRoot, _ := workspace.FindFromCwd()
if townRoot != "" {
b := beads.New(townRoot)
location := filepath.Join("deacon", "dogs", name)
issue, err := b.CreateDogAgentBead(name, location)
if err != nil {
// Non-fatal: warn but don't fail dog creation
fmt.Printf(" Warning: could not create agent bead: %v\n", err)
} else {
fmt.Printf(" Agent bead: %s\n", issue.ID)
}
}
return nil
}
func runDogRemove(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
var names []string
if dogRemoveAll {
dogs, err := mgr.List()
if err != nil {
return fmt.Errorf("listing dogs: %w", err)
}
for _, d := range dogs {
names = append(names, d.Name)
}
if len(names) == 0 {
fmt.Println("No dogs in kennel")
return nil
}
} else {
names = args
}
// Get beads client for cleanup
townRoot, _ := workspace.FindFromCwd()
var b *beads.Beads
if townRoot != "" {
b = beads.New(townRoot)
}
for _, name := range names {
d, err := mgr.Get(name)
if err != nil {
fmt.Printf("Warning: dog %s not found, skipping\n", name)
continue
}
// Check if working
if d.State == dog.StateWorking && !dogForce {
return fmt.Errorf("dog %s is working (use --force to remove anyway)", name)
}
if err := mgr.Remove(name); err != nil {
return fmt.Errorf("removing dog %s: %w", name, err)
}
fmt.Printf("✓ Removed dog %s\n", name)
// Delete agent bead for the dog
if b != nil {
if err := b.DeleteDogAgentBead(name); err != nil {
// Non-fatal: warn but don't fail dog removal
fmt.Printf(" Warning: could not delete agent bead: %v\n", err)
}
}
}
return nil
}
func runDogList(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
dogs, err := mgr.List()
if err != nil {
return fmt.Errorf("listing dogs: %w", err)
}
if len(dogs) == 0 {
if dogListJSON {
fmt.Println("[]")
} else {
fmt.Println("No dogs in kennel")
}
return nil
}
if dogListJSON {
type DogListItem struct {
Name string `json:"name"`
State dog.State `json:"state"`
Work string `json:"work,omitempty"`
LastActive time.Time `json:"last_active"`
Worktrees map[string]string `json:"worktrees,omitempty"`
}
var items []DogListItem
for _, d := range dogs {
items = append(items, DogListItem{
Name: d.Name,
State: d.State,
Work: d.Work,
LastActive: d.LastActive,
Worktrees: d.Worktrees,
})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(items)
}
// Pretty print
fmt.Println(style.Bold.Render("The Pack"))
fmt.Println()
idleCount := 0
workingCount := 0
for _, d := range dogs {
stateIcon := "○"
stateStyle := style.Dim
if d.State == dog.StateWorking {
stateIcon = "●"
stateStyle = style.Bold
workingCount++
} else {
idleCount++
}
line := fmt.Sprintf(" %s %s", stateIcon, stateStyle.Render(d.Name))
if d.Work != "" {
line += fmt.Sprintf(" → %s", style.Dim.Render(d.Work))
}
fmt.Println(line)
}
fmt.Println()
fmt.Printf(" %d idle, %d working\n", idleCount, workingCount)
return nil
}
func runDogCall(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
if dogCallAll {
// Wake all idle dogs
dogs, err := mgr.List()
if err != nil {
return fmt.Errorf("listing dogs: %w", err)
}
woken := 0
for _, d := range dogs {
if d.State == dog.StateIdle {
if err := mgr.SetState(d.Name, dog.StateIdle); err != nil {
fmt.Printf("Warning: failed to wake %s: %v\n", d.Name, err)
continue
}
woken++
fmt.Printf("✓ Called %s\n", d.Name)
}
}
if woken == 0 {
fmt.Println("No idle dogs to call")
} else {
fmt.Printf("\n%d dog(s) ready\n", woken)
}
return nil
}
if len(args) > 0 {
// Wake specific dog
name := args[0]
d, err := mgr.Get(name)
if err != nil {
return fmt.Errorf("getting dog %s: %w", name, err)
}
if d.State == dog.StateWorking {
fmt.Printf("Dog %s is already working\n", name)
return nil
}
if err := mgr.SetState(name, dog.StateIdle); err != nil {
return fmt.Errorf("waking dog %s: %w", name, err)
}
fmt.Printf("✓ Called %s - ready for work\n", name)
return nil
}
// Wake one idle dog
d, err := mgr.GetIdleDog()
if err != nil {
return fmt.Errorf("getting idle dog: %w", err)
}
if d == nil {
fmt.Println("No idle dogs available")
return nil
}
if err := mgr.SetState(d.Name, dog.StateIdle); err != nil {
return fmt.Errorf("waking dog %s: %w", d.Name, err)
}
fmt.Printf("✓ Called %s - ready for work\n", d.Name)
return nil
}
func runDogStatus(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
if len(args) > 0 {
// Show specific dog status
name := args[0]
return showDogStatus(mgr, name)
}
// Show pack summary
return showPackStatus(mgr)
}
func runDogDone(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
var name string
if len(args) > 0 {
name = args[0]
} else {
// Try to detect from BD_ACTOR (e.g., "deacon/dogs/alpha")
actor := os.Getenv("BD_ACTOR")
if actor != "" && strings.HasPrefix(actor, "deacon/dogs/") {
name = strings.TrimPrefix(actor, "deacon/dogs/")
}
if name == "" {
return fmt.Errorf("no dog name provided and could not detect from BD_ACTOR")
}
}
if err := mgr.ClearWork(name); err != nil {
return fmt.Errorf("marking dog %s as done: %w", name, err)
}
fmt.Printf("✓ %s marked as idle (ready for new work)\n", name)
return nil
}
func showDogStatus(mgr *dog.Manager, name string) error {
d, err := mgr.Get(name)
if err != nil {
return fmt.Errorf("getting dog %s: %w", name, err)
}
if dogStatusJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(d)
}
fmt.Printf("Dog: %s\n\n", style.Bold.Render(d.Name))
fmt.Printf(" State: %s\n", d.State)
if d.Work != "" {
fmt.Printf(" Work: %s\n", d.Work)
} else {
fmt.Printf(" Work: %s\n", style.Dim.Render("(none)"))
}
fmt.Printf(" Path: %s\n", d.Path)
fmt.Printf(" Last Active: %s\n", dogFormatTimeAgo(d.LastActive))
fmt.Printf(" Created: %s\n", d.CreatedAt.Format("2006-01-02 15:04"))
if len(d.Worktrees) > 0 {
fmt.Println("\nWorktrees:")
for rigName, path := range d.Worktrees {
// Check if worktree exists
exists := "✓"
if _, err := os.Stat(path); os.IsNotExist(err) {
exists = "✗"
}
fmt.Printf(" %s %s: %s\n", exists, rigName, path)
}
}
// Check for tmux session
townRoot, _ := workspace.FindFromCwd()
if townRoot != "" {
townName, err := workspace.GetTownName(townRoot)
if err == nil {
sessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, name)
tm := tmux.NewTmux()
if has, _ := tm.HasSession(sessionName); has {
fmt.Printf("\nSession: %s (running)\n", sessionName)
}
}
}
return nil
}
func showPackStatus(mgr *dog.Manager) error {
dogs, err := mgr.List()
if err != nil {
return fmt.Errorf("listing dogs: %w", err)
}
if dogStatusJSON {
type PackStatus struct {
Total int `json:"total"`
Idle int `json:"idle"`
Working int `json:"working"`
KennelDir string `json:"kennel_dir"`
}
townRoot, _ := workspace.FindFromCwd()
status := PackStatus{
Total: len(dogs),
KennelDir: filepath.Join(townRoot, "deacon", "dogs"),
}
for _, d := range dogs {
if d.State == dog.StateIdle {
status.Idle++
} else {
status.Working++
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(status)
}
fmt.Println(style.Bold.Render("Pack Status"))
fmt.Println()
if len(dogs) == 0 {
fmt.Println(" No dogs in kennel")
fmt.Println()
fmt.Println(" Use 'gt dog add <name>' to add a dog")
return nil
}
idleCount := 0
workingCount := 0
for _, d := range dogs {
if d.State == dog.StateIdle {
idleCount++
} else {
workingCount++
}
}
fmt.Printf(" Total: %d\n", len(dogs))
fmt.Printf(" Idle: %d\n", idleCount)
fmt.Printf(" Working: %d\n", workingCount)
if idleCount > 0 {
fmt.Println()
fmt.Println(style.Dim.Render(" Ready for work. Use 'gt dog call' to wake."))
}
return nil
}
// dogFormatTimeAgo formats a time as a relative string like "2 hours ago".
func dogFormatTimeAgo(t time.Time) string {
if t.IsZero() {
return "(unknown)"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
mins := int(d.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
case d < 24*time.Hour:
hours := int(d.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
default:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}
// runDogDispatch dispatches plugin execution to a dog worker.
func runDogDispatch(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Get rig names for plugin scanner
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
return fmt.Errorf("loading rigs config: %w", err)
}
var rigNames []string
for rigName := range rigsConfig.Rigs {
rigNames = append(rigNames, rigName)
}
// If --rig specified, search only that rig
if dogDispatchRig != "" {
rigNames = []string{dogDispatchRig}
}
// Find the plugin using scanner
scanner := plugin.NewScanner(townRoot, rigNames)
p, err := scanner.GetPlugin(dogDispatchPlugin)
if err != nil {
return fmt.Errorf("finding plugin: %w", err)
}
// Get dog manager (reuse rigsConfig from above)
mgr := dog.NewManager(townRoot, rigsConfig)
// Find target dog
var targetDog *dog.Dog
var dogCreated bool
if dogDispatchDog != "" {
// Specific dog requested
targetDog, err = mgr.Get(dogDispatchDog)
if err != nil {
return fmt.Errorf("getting dog %s: %w", dogDispatchDog, err)
}
if targetDog.State == dog.StateWorking {
return fmt.Errorf("dog %s is already working", dogDispatchDog)
}
} else {
// Find idle dog from pool
targetDog, err = mgr.GetIdleDog()
if err != nil {
return fmt.Errorf("finding idle dog: %w", err)
}
if targetDog == nil {
if dogDispatchCreate {
// Create a new dog (reuse generateDogName from sling_dog.go)
newName := generateDogName(mgr)
if dogDispatchDryRun {
targetDog = &dog.Dog{Name: newName, State: dog.StateIdle}
dogCreated = true
} else {
targetDog, err = mgr.Add(newName)
if err != nil {
return fmt.Errorf("creating dog %s: %w", newName, err)
}
dogCreated = true
// Create agent bead for the dog
b := beads.New(townRoot)
location := filepath.Join("deacon", "dogs", newName)
if _, beadErr := b.CreateDogAgentBead(newName, location); beadErr != nil {
// Non-fatal warning
if !dogDispatchJSON {
fmt.Printf(" Warning: could not create agent bead: %v\n", beadErr)
}
}
}
} else {
return fmt.Errorf("no idle dogs available (use --create to add one)")
}
}
}
// Prepare dispatch result for JSON output
workDesc := fmt.Sprintf("plugin:%s", p.Name)
result := dogDispatchResult{
Plugin: p.Name,
PluginPath: p.Path,
Dog: targetDog.Name,
DogCreated: dogCreated,
Work: workDesc,
DryRun: dogDispatchDryRun,
}
if p.RigName != "" {
result.PluginRig = p.RigName
}
// Dry-run mode: show what would happen and exit
if dogDispatchDryRun {
if dogDispatchJSON {
return json.NewEncoder(os.Stdout).Encode(result)
}
fmt.Printf("Dry run - would dispatch:\n")
fmt.Printf(" Plugin: %s\n", p.Name)
if p.RigName != "" {
fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name)
} else {
fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name)
}
fmt.Printf(" Dog: %s%s\n", targetDog.Name, ifStr(dogCreated, " (would create)", ""))
fmt.Printf(" Work: %s\n", workDesc)
return nil
}
// Assign work FIRST (before sending mail) to prevent race condition
// If this fails, we haven't sent any mail yet
if err := mgr.AssignWork(targetDog.Name, workDesc); err != nil {
return fmt.Errorf("assigning work to dog: %w", err)
}
// Create and send mail message with plugin instructions
dogAddress := fmt.Sprintf("deacon/dogs/%s", targetDog.Name)
subject := fmt.Sprintf("Plugin: %s", p.Name)
body := formatPluginMailBody(p)
router := mail.NewRouterWithTownRoot(townRoot, townRoot)
msg := &mail.Message{
From: "deacon/",
To: dogAddress,
Subject: subject,
Body: body,
Timestamp: time.Now(),
}
if err := router.Send(msg); err != nil {
// Rollback: clear work assignment since mail failed
if clearErr := mgr.ClearWork(targetDog.Name); clearErr != nil {
// Log rollback failure but return original error
if !dogDispatchJSON {
fmt.Printf(" Warning: rollback failed: %v\n", clearErr)
}
}
return fmt.Errorf("sending plugin mail to dog: %w", err)
}
// Spawn a session for the dog to execute the work.
// Without a session, the dog's mail inbox is never checked.
// See: https://github.com/steveyegge/gastown/issues/XXX (dog dispatch doesn't execute)
t := tmux.NewTmux()
townName, err := workspace.GetTownName(townRoot)
if err != nil {
townName = "gt" // fallback
}
dogSessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, targetDog.Name)
// Kill any stale session first
if has, _ := t.HasSession(dogSessionName); has {
_ = t.KillSessionWithProcesses(dogSessionName)
}
// Build startup command with initial prompt to check mail and execute plugin
initialPrompt := fmt.Sprintf("I am dog %s. Check my mail inbox with 'gt mail inbox' and execute the plugin instructions I received.", targetDog.Name)
startCmd := config.BuildAgentStartupCommand("dog", "", townRoot, targetDog.Path, initialPrompt)
// Create session from dog's directory
if err := t.NewSessionWithCommand(dogSessionName, targetDog.Path, startCmd); err != nil {
if !dogDispatchJSON {
fmt.Printf(" Warning: could not spawn dog session: %v\n", err)
}
// Non-fatal: mail was sent, dog is marked as working, but no session to execute
// The deacon or human can manually start the session later
} else {
// Set environment for the dog session
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "dog",
AgentName: targetDog.Name,
TownRoot: townRoot,
})
for k, v := range envVars {
_ = t.SetEnvironment(dogSessionName, k, v)
}
}
// Success - output result
if dogDispatchJSON {
return json.NewEncoder(os.Stdout).Encode(result)
}
fmt.Printf("%s Found plugin: %s\n", style.Bold.Render("✓"), p.Name)
if p.RigName != "" {
fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name)
} else {
fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name)
}
if dogCreated {
fmt.Printf("%s Created dog %s (pool was empty)\n", style.Bold.Render("✓"), targetDog.Name)
}
fmt.Printf("%s Dispatching to dog: %s\n", style.Bold.Render("🐕"), targetDog.Name)
fmt.Printf("%s Plugin dispatched (non-blocking)\n", style.Bold.Render("✓"))
fmt.Printf(" Dog: %s\n", targetDog.Name)
fmt.Printf(" Work: %s\n", workDesc)
return nil
}
// dogDispatchResult is the JSON output for gt dog dispatch.
type dogDispatchResult struct {
Plugin string `json:"plugin"`
PluginRig string `json:"plugin_rig,omitempty"`
PluginPath string `json:"plugin_path"`
Dog string `json:"dog"`
DogCreated bool `json:"dog_created,omitempty"`
Work string `json:"work"`
DryRun bool `json:"dry_run,omitempty"`
}
// ifStr returns ifTrue if cond is true, otherwise ifFalse.
func ifStr(cond bool, ifTrue, ifFalse string) string {
if cond {
return ifTrue
}
return ifFalse
}
// formatPluginMailBody formats the plugin as instructions for the dog.
func formatPluginMailBody(p *plugin.Plugin) string {
var sb strings.Builder
sb.WriteString("Execute the following plugin:\n\n")
sb.WriteString(fmt.Sprintf("**Plugin**: %s\n", p.Name))
sb.WriteString(fmt.Sprintf("**Description**: %s\n", p.Description))
if p.RigName != "" {
sb.WriteString(fmt.Sprintf("**Rig**: %s\n", p.RigName))
}
if p.Execution != nil && p.Execution.Timeout != "" {
sb.WriteString(fmt.Sprintf("**Timeout**: %s\n", p.Execution.Timeout))
}
sb.WriteString("\n---\n\n")
sb.WriteString("## Instructions\n\n")
sb.WriteString(p.Instructions)
sb.WriteString("\n\n---\n\n")
sb.WriteString("After completion:\n")
sb.WriteString("1. Create a wisp to record the result (success/failure)\n")
sb.WriteString("2. Send DOG_DONE mail to deacon/\n")
sb.WriteString("3. Return to idle state\n")
return sb.String()
}