package cmd import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" ) // Park command parks work on a gate, allowing agent to exit safely. // When the gate closes, waiters are notified and can resume. var parkCmd = &cobra.Command{ Use: "park ", GroupID: GroupWork, Short: "Park work on a gate for async resumption", Long: `Park current work on a gate, allowing the agent to exit safely. When you need to wait for an external condition (timer, CI, human approval), park your work on a gate. When the gate closes, you'll receive wake mail. The park command: 1. Saves your current hook state (molecule/bead you're working on) 2. Adds you as a waiter on the gate 3. Stores context notes in the parked state After parking, you can exit the session safely. Use 'gt resume' to check for cleared gates and continue work. Examples: # Create a timer gate and park work on it bd gate create --await timer:30m --title "Coffee break" gt park -m "Taking a break, will resume auth work" # Park on a human approval gate bd gate create --await human:deploy-approval --notify ops/ gt park -m "Deploy staged, awaiting approval" # Park on a GitHub Actions gate bd gate create --await gh:run:123456789 gt park -m "Waiting for CI to complete"`, Args: cobra.ExactArgs(1), RunE: runPark, } var ( parkMessage string parkDryRun bool ) func init() { parkCmd.Flags().StringVarP(&parkMessage, "message", "m", "", "Context notes for resumption") parkCmd.Flags().BoolVarP(&parkDryRun, "dry-run", "n", false, "Show what would be done without executing") rootCmd.AddCommand(parkCmd) } // ParkedWork represents work state saved when parking on a gate. type ParkedWork struct { // AgentID is the agent that parked (e.g., "gastown/crew/max") AgentID string `json:"agent_id"` // GateID is the gate we're parked on GateID string `json:"gate_id"` // BeadID is the bead/molecule we were working on BeadID string `json:"bead_id,omitempty"` // Formula is the formula attached to the work (if any) Formula string `json:"formula,omitempty"` // Context is additional context notes from the agent Context string `json:"context,omitempty"` // ParkedAt is when the work was parked ParkedAt time.Time `json:"parked_at"` } func runPark(cmd *cobra.Command, args []string) error { gateID := args[0] // Verify gate exists and is open gateCheck := exec.Command("bd", "gate", "show", gateID, "--json") gateOutput, err := gateCheck.Output() if err != nil { return fmt.Errorf("gate '%s' not found or not accessible", gateID) } // Parse gate info to verify it's open var gateInfo struct { ID string `json:"id"` Status string `json:"status"` } if err := json.Unmarshal(gateOutput, &gateInfo); err != nil { return fmt.Errorf("parsing gate info: %w", err) } if gateInfo.Status == "closed" { return fmt.Errorf("gate '%s' is already closed - nothing to park on", gateID) } // Detect agent identity agentID, _, cloneRoot, err := resolveSelfTarget() if err != nil { return fmt.Errorf("detecting agent identity: %w", err) } // Read current pinned bead (if any) var beadID, formula, hookContext string workDir, err := findLocalBeadsDir() if err == nil { b := beads.New(workDir) pinnedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusPinned, Assignee: agentID, Priority: -1, }) if err == nil && len(pinnedBeads) > 0 { beadID = pinnedBeads[0].ID // Extract molecule from attachment fields if attachment := beads.ParseAttachmentFields(pinnedBeads[0]); attachment != nil { formula = attachment.AttachedMolecule } // Context is part of the bead description, not stored separately hookContext = pinnedBeads[0].Description } } // Build context combining hook context and new message context := "" if hookContext != "" && parkMessage != "" { context = hookContext + "\n---\n" + parkMessage } else if hookContext != "" { context = hookContext } else if parkMessage != "" { context = parkMessage } // Create parked work record parked := &ParkedWork{ AgentID: agentID, GateID: gateID, BeadID: beadID, Formula: formula, Context: context, ParkedAt: time.Now(), } if parkDryRun { fmt.Printf("Would park on gate %s\n", gateID) fmt.Printf(" Agent: %s\n", agentID) if beadID != "" { fmt.Printf(" Bead: %s\n", beadID) } if formula != "" { fmt.Printf(" Formula: %s\n", formula) } if context != "" { fmt.Printf(" Context: %s\n", context) } fmt.Printf("Would add %s as waiter on gate\n", agentID) return nil } // Add agent as waiter on the gate waitCmd := exec.Command("bd", "gate", "wait", gateID, "--notify", agentID) if err := waitCmd.Run(); err != nil { // Not fatal - might already be a waiter fmt.Printf("%s Note: could not add as waiter (may already be registered)\n", style.Dim.Render("⚠")) } // Store parked work in a file (alongside hook files) parkedPath := parkedWorkPath(cloneRoot, agentID) parkedJSON, err := json.MarshalIndent(parked, "", " ") if err != nil { return fmt.Errorf("marshaling parked work: %w", err) } if err := os.WriteFile(parkedPath, parkedJSON, 0644); err != nil { return fmt.Errorf("writing parked state: %w", err) } fmt.Printf("%s Parked work on gate %s\n", style.Bold.Render("🅿️"), gateID) if beadID != "" { fmt.Printf(" Working on: %s\n", beadID) } if context != "" { // Truncate for display displayContext := context if len(displayContext) > 80 { displayContext = displayContext[:77] + "..." } fmt.Printf(" Context: %s\n", displayContext) } fmt.Printf("\n%s You can now safely exit. Run 'gt resume' to check for cleared gates.\n", style.Dim.Render("→")) return nil } // parkedWorkPath returns the file path for an agent's parked work state. func parkedWorkPath(cloneRoot, agentID string) string { return filepath.Join(cloneRoot, ".beads", fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_"))) } // readParkedWork reads the parked work state for an agent. func readParkedWork(cloneRoot, agentID string) (*ParkedWork, error) { parkedPath := parkedWorkPath(cloneRoot, agentID) data, err := os.ReadFile(parkedPath) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, err } var parked ParkedWork if err := json.Unmarshal(data, &parked); err != nil { return nil, err } return &parked, nil } // clearParkedWork removes the parked work state for an agent. func clearParkedWork(cloneRoot, agentID string) error { parkedPath := parkedWorkPath(cloneRoot, agentID) err := os.Remove(parkedPath) if os.IsNotExist(err) { return nil } return err }