@@ -1,6 +1,7 @@
1package tools
1package tools
@@ -14,6 +15,7 @@ import (
16 "charm.land/fantasy"
17 "charm.land/fantasy"
18+ "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/filepathext"
19 "github.com/charmbracelet/crush/internal/filepathext"
18 "github.com/charmbracelet/crush/internal/fsext"
20 "github.com/charmbracelet/crush/internal/fsext"
@@ -48,7 +50,7 @@ type GlobResponseMetadata struct {
48 Truncated bool `json:"truncated"`
50 Truncated bool `json:"truncated"`
51−func NewGlobTool(workingDir string) fantasy.AgentTool {
53+func NewGlobTool(workingDir string, cfg config.ToolGlob) fantasy.AgentTool {
52 return fantasy.NewAgentTool(
54 return fantasy.NewAgentTool(
53 GlobToolName,
55 GlobToolName,
54 globDescription(),
56 globDescription(),
@@ -59,7 +61,13 @@ func NewGlobTool(workingDir string) fantasy.AgentTool {
60 searchPath := cmp.Or(params.Path, workingDir)
62 searchPath := cmp.Or(params.Path, workingDir)
64+ // Bound the search so a huge or symlink-heavy root (e.g. $HOME
65+ // or a module cache) fails cleanly instead of pinning the CPU
66+ // and hanging the agent.
67+ searchCtx, cancel := context.WithTimeout(ctx, cfg.GetTimeout())
62− files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
70+ files, truncated, err := globFiles(searchCtx, params.Pattern, searchPath, 100)
63 if err != nil {
71 if err != nil {
64 return fantasy.NewTextErrorResponse(fmt.Sprintf("error finding files: %v", err)), nil
72 return fantasy.NewTextErrorResponse(fmt.Sprintf("error finding files: %v", err)), nil
@@ -87,38 +95,87 @@ func NewGlobTool(workingDir string) fantasy.AgentTool {
89func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
97func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
98+ // Scope the walk to the pattern's literal directory prefix. A pattern
99+ // like "internal/agent/*.go" only needs to walk "internal/agent", so we
100+ // start there instead of enumerating the entire tree and filtering.
101+ // Patterns that begin with a wildcard (e.g. "**/foo.go") have no prefix
102+ // and still walk from searchPath.
103+ prefix, rest := filepathext.SplitGlobPrefix(pattern)
104+ walkRoot := searchPath
105+ walkPattern := pattern
107+ walkRoot = filepath.Join(searchPath, prefix)
90− cmdRg := getRgCmd(ctx, pattern)
111+ cmdRg := getRgCmd(ctx, walkPattern)
91 if cmdRg != nil {
112 if cmdRg != nil {
92− cmdRg.Dir = searchPath
113+ cmdRg.Dir = walkRoot
93− matches, err := runRipgrep(cmdRg, searchPath, limit)
114+ matches, err := runRipgrep(cmdRg, walkRoot, limit)
94 if err == nil {
115 if err == nil {
95 return matches, len(matches) >= limit && limit > 0, nil
116 return matches, len(matches) >= limit && limit > 0, nil
97 slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
118 slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
100− return fsext.GlobGitignoreAware(pattern, searchPath, limit)
121+ return fsext.GlobGitignoreAwareCtx(ctx, walkPattern, walkRoot, limit)
103func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
124func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
104− out, err := cmd.CombinedOutput()
125+ // Stream ripgrep's stdout instead of buffering the whole file list.
126+ // Over a huge root (e.g. $HOME) the full --files listing can be
127+ // hundreds of MB; reading it all at once and then sorting allocated
128+ // gigabytes. We read incrementally and stop once we have a bounded
129+ // pool of candidates.
131+ // We collect more than `limit` so the shortest-path preference below
132+ // still has something to choose from, but the pool is capped so memory
133+ // stays small (a few thousand paths) no matter how large the tree is.
134+ candidatePool := max(limit*20, 1000)
136+ stdout, err := cmd.StdoutPipe()
105 if err != nil {
137 if err != nil {
106− if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
138+ return nil, fmt.Errorf("ripgrep: %w", err)
107− return nil, nil
139+ }
108− }
140+ var stderr bytes.Buffer
141+ cmd.Stderr = &stderr
143+ if err := cmd.Start(); err != nil {
109− return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
144+ return nil, fmt.Errorf("ripgrep: %w", err)
112 var matches []string
147 var matches []string
113− for p := range bytes.SplitSeq(out, []byte{0}) {
148+ reader := bufio.NewReader(stdout)
150+ path, err := reader.ReadString(0)
114− if len(p) == 0 {
151+ if len(path) > 0 {
115− continue
152+ path = strings.TrimRight(path, "\x00")
154+ absPath := filepathext.SmartJoin(searchRoot, path)
155+ if !fsext.SkipHidden(absPath) {
156+ matches = append(matches, absPath)
161+ break // EOF or read error; drain handled by Wait below.
117− absPath := filepathext.SmartJoin(searchRoot, string(p))
163+ if len(matches) >= candidatePool {
118− if fsext.SkipHidden(absPath) {
164+ // Enough candidates; stop reading and let the process be
119− continue
165+ // killed by the command context / Wait. Draining the rest
166+ // would just buffer paths we are going to discard.
171+ // Close our end so ripgrep gets SIGPIPE and stops, then reap it.
173+ waitErr := cmd.Wait()
174+ if waitErr != nil && len(matches) == 0 {
175+ if ee, ok := waitErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
176+ return nil, nil // No matches.
121− matches = append(matches, absPath)
178+ return nil, fmt.Errorf("ripgrep: %w\n%s", waitErr, stderr.String())
124 sort.SliceStable(matches, func(i, j int) bool {
181 sort.SliceStable(matches, func(i, j int) bool {