@@ -1,6 +1,7 @@
11package tools
22
33import (
4+ "bufio"
45 "bytes"
56 "cmp"
67 "context"
@@ -14,6 +15,7 @@ import (
1415 "strings"
1516
1617 "charm.land/fantasy"
18+ "github.com/charmbracelet/crush/internal/config"
1719 "github.com/charmbracelet/crush/internal/filepathext"
1820 "github.com/charmbracelet/crush/internal/fsext"
1921)
@@ -48,7 +50,7 @@ type GlobResponseMetadata struct {
4850 Truncated bool `json:"truncated"`
4951}
5052
51−func NewGlobTool(workingDir string) fantasy.AgentTool {
53+func NewGlobTool(workingDir string, cfg config.ToolGlob) fantasy.AgentTool {
5254 return fantasy.NewAgentTool(
5355 GlobToolName,
5456 globDescription(),
@@ -59,7 +61,13 @@ func NewGlobTool(workingDir string) fantasy.AgentTool {
5961
6062 searchPath := cmp.Or(params.Path, workingDir)
6163
62− files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
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())
68+ defer cancel()
69+
70+ files, truncated, err := globFiles(searchCtx, params.Pattern, searchPath, 100)
6371 if err != nil {
6472 return fantasy.NewTextErrorResponse(fmt.Sprintf("error finding files: %v", err)), nil
6573 }
@@ -87,38 +95,87 @@ func NewGlobTool(workingDir string) fantasy.AgentTool {
8795}
8896
8997func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
90− cmdRg := getRgCmd(ctx, pattern)
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
106+ if prefix != "" {
107+ walkRoot = filepath.Join(searchPath, prefix)
108+ walkPattern = rest
109+ }
110+
111+ cmdRg := getRgCmd(ctx, walkPattern)
91112 if cmdRg != nil {
92− cmdRg.Dir = searchPath
93− matches, err := runRipgrep(cmdRg, searchPath, limit)
113+ cmdRg.Dir = walkRoot
114+ matches, err := runRipgrep(cmdRg, walkRoot, limit)
94115 if err == nil {
95116 return matches, len(matches) >= limit && limit > 0, nil
96117 }
97118 slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
98119 }
99120
100− return fsext.GlobGitignoreAware(pattern, searchPath, limit)
121+ return fsext.GlobGitignoreAwareCtx(ctx, walkPattern, walkRoot, limit)
101122}
102123
103124func 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.
130+ //
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)
135+
136+ stdout, err := cmd.StdoutPipe()
105137 if err != nil {
106− if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
107− return nil, nil
108− }
109− return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
138+ return nil, fmt.Errorf("ripgrep: %w", err)
139+ }
140+ var stderr bytes.Buffer
141+ cmd.Stderr = &stderr
142+
143+ if err := cmd.Start(); err != nil {
144+ return nil, fmt.Errorf("ripgrep: %w", err)
110145 }
111146
112147 var matches []string
113− for p := range bytes.SplitSeq(out, []byte{0}) {
114− if len(p) == 0 {
115− continue
148+ reader := bufio.NewReader(stdout)
149+ for {
150+ path, err := reader.ReadString(0)
151+ if len(path) > 0 {
152+ path = strings.TrimRight(path, "\x00")
153+ if path != "" {
154+ absPath := filepathext.SmartJoin(searchRoot, path)
155+ if !fsext.SkipHidden(absPath) {
156+ matches = append(matches, absPath)
157+ }
158+ }
159+ }
160+ if err != nil {
161+ break // EOF or read error; drain handled by Wait below.
116162 }
117− absPath := filepathext.SmartJoin(searchRoot, string(p))
118− if fsext.SkipHidden(absPath) {
119− continue
163+ if len(matches) >= candidatePool {
164+ // Enough candidates; stop reading and let the process be
165+ // killed by the command context / Wait. Draining the rest
166+ // would just buffer paths we are going to discard.
167+ break
168+ }
169+ }
170+
171+ // Close our end so ripgrep gets SIGPIPE and stops, then reap it.
172+ _ = stdout.Close()
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.
120177 }
121− matches = append(matches, absPath)
178+ return nil, fmt.Errorf("ripgrep: %w\n%s", waitErr, stderr.String())
122179 }
123180
124181 sort.SliceStable(matches, func(i, j int) bool {