Skip to content

Commit ed3d7a5

Browse files
committed
feat(tools): add glob tool with timeout protection
- Override OpenCode's built-in glob with 60s timeout - Kill process on expiration to prevent indefinite hanging - Reuse grep's CLI resolver for ripgrep detection Generated by [OpenCode](https://opencode.ai/)
1 parent b77dd2f commit ed3d7a5

File tree

9 files changed

+241
-0
lines changed

9 files changed

+241
-0
lines changed

README.ko.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,12 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
190190
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
191191
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
192192

193+
#### Glob
194+
195+
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
196+
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
197+
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
198+
193199
#### 내장 MCPs
194200

195201
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ The features you use in your editor—other agents cannot access them. Oh My Ope
187187
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
188188
- This tool enforces strict limits and completely replaces the built-in `grep`.
189189

190+
#### Glob
191+
192+
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
193+
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
194+
- This tool enforces timeouts and kills the process on expiration.
195+
190196
#### Built-in MCPs
191197

192198
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.

src/tools/glob/cli.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { spawn } from "bun"
2+
import {
3+
resolveGrepCli,
4+
DEFAULT_TIMEOUT_MS,
5+
DEFAULT_LIMIT,
6+
DEFAULT_MAX_DEPTH,
7+
DEFAULT_MAX_OUTPUT_BYTES,
8+
RG_FILES_FLAGS,
9+
} from "./constants"
10+
import type { GlobOptions, GlobResult, FileMatch } from "./types"
11+
import { stat } from "node:fs/promises"
12+
13+
function buildRgArgs(options: GlobOptions): string[] {
14+
const args: string[] = [
15+
...RG_FILES_FLAGS,
16+
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
17+
]
18+
19+
if (options.hidden) args.push("--hidden")
20+
if (options.noIgnore) args.push("--no-ignore")
21+
22+
args.push(`--glob=${options.pattern}`)
23+
24+
return args
25+
}
26+
27+
function buildFindArgs(options: GlobOptions): string[] {
28+
const args: string[] = ["."]
29+
30+
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
31+
args.push("-maxdepth", String(maxDepth))
32+
33+
args.push("-type", "f")
34+
args.push("-name", options.pattern)
35+
36+
if (!options.hidden) {
37+
args.push("-not", "-path", "*/.*")
38+
}
39+
40+
return args
41+
}
42+
43+
async function getFileMtime(filePath: string): Promise<number> {
44+
try {
45+
const stats = await stat(filePath)
46+
return stats.mtime.getTime()
47+
} catch {
48+
return 0
49+
}
50+
}
51+
52+
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
53+
const cli = resolveGrepCli()
54+
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
55+
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
56+
57+
const isRg = cli.backend === "rg"
58+
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
59+
60+
const paths = options.paths?.length ? options.paths : ["."]
61+
if (isRg) {
62+
args.push(...paths)
63+
}
64+
65+
const cwd = paths[0] || "."
66+
67+
const proc = spawn([cli.path, ...args], {
68+
stdout: "pipe",
69+
stderr: "pipe",
70+
cwd: isRg ? undefined : cwd,
71+
})
72+
73+
const timeoutPromise = new Promise<never>((_, reject) => {
74+
const id = setTimeout(() => {
75+
proc.kill()
76+
reject(new Error(`Glob search timeout after ${timeout}ms`))
77+
}, timeout)
78+
proc.exited.then(() => clearTimeout(id))
79+
})
80+
81+
try {
82+
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
83+
const stderr = await new Response(proc.stderr).text()
84+
const exitCode = await proc.exited
85+
86+
if (exitCode > 1 && stderr.trim()) {
87+
return {
88+
files: [],
89+
totalFiles: 0,
90+
truncated: false,
91+
error: stderr.trim(),
92+
}
93+
}
94+
95+
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
96+
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
97+
98+
const lines = outputToProcess.trim().split("\n").filter(Boolean)
99+
100+
const files: FileMatch[] = []
101+
let truncated = false
102+
103+
for (const line of lines) {
104+
if (files.length >= limit) {
105+
truncated = true
106+
break
107+
}
108+
109+
const filePath = isRg ? line : `${cwd}/${line}`
110+
const mtime = await getFileMtime(filePath)
111+
files.push({ path: filePath, mtime })
112+
}
113+
114+
files.sort((a, b) => b.mtime - a.mtime)
115+
116+
return {
117+
files,
118+
totalFiles: files.length,
119+
truncated: truncated || truncatedOutput,
120+
}
121+
} catch (e) {
122+
return {
123+
files: [],
124+
totalFiles: 0,
125+
truncated: false,
126+
error: e instanceof Error ? e.message : String(e),
127+
}
128+
}
129+
}

src/tools/glob/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export { resolveGrepCli, type GrepBackend } from "../grep/constants"
2+
3+
export const DEFAULT_TIMEOUT_MS = 60_000
4+
export const DEFAULT_LIMIT = 100
5+
export const DEFAULT_MAX_DEPTH = 20
6+
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
7+
8+
export const RG_FILES_FLAGS = [
9+
"--files",
10+
"--color=never",
11+
"--glob=!.git/*",
12+
] as const

src/tools/glob/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { glob } from "./tools"
2+
3+
export { glob }

src/tools/glob/tools.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { tool } from "@opencode-ai/plugin/tool"
2+
import { runRgFiles } from "./cli"
3+
import { formatGlobResult } from "./utils"
4+
5+
export const glob = tool({
6+
description:
7+
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
8+
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
9+
"Returns matching file paths sorted by modification time. " +
10+
"Use this tool when you need to find files by name patterns.",
11+
args: {
12+
pattern: tool.schema.string().describe("The glob pattern to match files against"),
13+
path: tool.schema
14+
.string()
15+
.optional()
16+
.describe(
17+
"The directory to search in. If not specified, the current working directory will be used. " +
18+
"IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " +
19+
"simply omit it for the default behavior. Must be a valid directory path if provided."
20+
),
21+
},
22+
execute: async (args) => {
23+
try {
24+
const paths = args.path ? [args.path] : undefined
25+
26+
const result = await runRgFiles({
27+
pattern: args.pattern,
28+
paths,
29+
})
30+
31+
return formatGlobResult(result)
32+
} catch (e) {
33+
return `Error: ${e instanceof Error ? e.message : String(e)}`
34+
}
35+
},
36+
})

src/tools/glob/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export interface FileMatch {
2+
path: string
3+
mtime: number
4+
}
5+
6+
export interface GlobResult {
7+
files: FileMatch[]
8+
totalFiles: number
9+
truncated: boolean
10+
error?: string
11+
}
12+
13+
export interface GlobOptions {
14+
pattern: string
15+
paths?: string[]
16+
hidden?: boolean
17+
noIgnore?: boolean
18+
maxDepth?: number
19+
timeout?: number
20+
limit?: number
21+
}

src/tools/glob/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { GlobResult } from "./types"
2+
3+
export function formatGlobResult(result: GlobResult): string {
4+
if (result.error) {
5+
return `Error: ${result.error}`
6+
}
7+
8+
if (result.files.length === 0) {
9+
return "No files found"
10+
}
11+
12+
const lines: string[] = []
13+
lines.push(`Found ${result.totalFiles} file(s)`)
14+
lines.push("")
15+
16+
for (const file of result.files) {
17+
lines.push(file.path)
18+
}
19+
20+
if (result.truncated) {
21+
lines.push("")
22+
lines.push("(Results are truncated. Consider using a more specific path or pattern.)")
23+
}
24+
25+
return lines.join("\n")
26+
}

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "./ast-grep"
1919

2020
import { grep } from "./grep"
21+
import { glob } from "./glob"
2122

2223
export const builtinTools = {
2324
lsp_hover,
@@ -34,4 +35,5 @@ export const builtinTools = {
3435
ast_grep_search,
3536
ast_grep_replace,
3637
grep,
38+
glob,
3739
}

0 commit comments

Comments
 (0)