fix: use ctx.directory instead of process.cwd() in tools for Desktop app support

Convert grep, glob, ast-grep, and session-manager tools from static exports to factory functions that receive PluginInput context. This allows them to use ctx.directory instead of process.cwd(), fixing issue #658 where tools search from wrong directory in OpenCode Desktop app.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim 2026-02-07 19:04:31 +09:00
parent 8e92704316
commit 476f154ef5
10 changed files with 282 additions and 287 deletions

View File

@ -86,6 +86,10 @@ import {
createTaskGetTool, createTaskGetTool,
createTaskList, createTaskList,
createTaskUpdateTool, createTaskUpdateTool,
createGrepTools,
createGlobTools,
createAstGrepTools,
createSessionManagerTools,
} from "./tools"; } from "./tools";
import { import {
CATEGORY_DESCRIPTIONS, CATEGORY_DESCRIPTIONS,
@ -538,6 +542,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const allTools: Record<string, ToolDefinition> = { const allTools: Record<string, ToolDefinition> = {
...builtinTools, ...builtinTools,
...createGrepTools(ctx),
...createGlobTools(ctx),
...createAstGrepTools(ctx),
...createSessionManagerTools(ctx),
...backgroundTools, ...backgroundTools,
call_omo_agent: callOmoAgent, call_omo_agent: callOmoAgent,
...(lookAt ? { look_at: lookAt } : {}), ...(lookAt ? { look_at: lookAt } : {}),

View File

@ -1,12 +1,4 @@
import type { ToolDefinition } from "@opencode-ai/plugin" export { createAstGrepTools } from "./tools"
import { ast_grep_search, ast_grep_replace } from "./tools"
export const builtinTools: Record<string, ToolDefinition> = {
ast_grep_search,
ast_grep_replace,
}
export { ast_grep_search, ast_grep_replace }
export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader" export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli" export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
export { checkEnvironment, formatEnvironmentCheck } from "./constants" export { checkEnvironment, formatEnvironmentCheck } from "./constants"

View File

@ -1,3 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants" import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli" import { runSg } from "./cli"
@ -34,80 +35,83 @@ function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
return null return null
} }
export const ast_grep_search: ToolDefinition = tool({ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
description: const ast_grep_search: ToolDefinition = tool({
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " + description:
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " + "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
"IMPORTANT: Patterns must be complete AST nodes (valid code). " + "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
"For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " + "IMPORTANT: Patterns must be complete AST nodes (valid code). " +
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'", "For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
args: { "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."), args: {
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"), lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"), paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
context: tool.schema.number().optional().describe("Context lines around match"), globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
}, context: tool.schema.number().optional().describe("Context lines around match"),
execute: async (args, context) => { },
try { execute: async (args, context) => {
const result = await runSg({ try {
pattern: args.pattern, const result = await runSg({
lang: args.lang as CliLanguage, pattern: args.pattern,
paths: args.paths, lang: args.lang as CliLanguage,
globs: args.globs, paths: args.paths ?? [ctx.directory],
context: args.context, globs: args.globs,
}) context: args.context,
})
let output = formatSearchResult(result) let output = formatSearchResult(result)
if (result.matches.length === 0 && !result.error) { if (result.matches.length === 0 && !result.error) {
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage) const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
if (hint) { if (hint) {
output += `\n\n${hint}` output += `\n\n${hint}`
}
} }
await showOutputToUser(context, output)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
await showOutputToUser(context, output)
return output
} }
},
})
await showOutputToUser(context, output) const ast_grep_replace: ToolDefinition = tool({
return output description:
} catch (e) { "Replace code patterns across filesystem with AST-aware rewriting. " +
const output = `Error: ${e instanceof Error ? e.message : String(e)}` "Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
await showOutputToUser(context, output) "Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
return output args: {
} pattern: tool.schema.string().describe("AST pattern to match"),
}, rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
}) lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
export const ast_grep_replace: ToolDefinition = tool({ globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
description: dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"),
"Replace code patterns across filesystem with AST-aware rewriting. " + },
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " + execute: async (args, context) => {
"Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'", try {
args: { const result = await runSg({
pattern: tool.schema.string().describe("AST pattern to match"), pattern: args.pattern,
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"), rewrite: args.rewrite,
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), lang: args.lang as CliLanguage,
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"), paths: args.paths ?? [ctx.directory],
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"), globs: args.globs,
dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"), updateAll: args.dryRun === false,
}, })
execute: async (args, context) => { const output = formatReplaceResult(result, args.dryRun !== false)
try { await showOutputToUser(context, output)
const result = await runSg({ return output
pattern: args.pattern, } catch (e) {
rewrite: args.rewrite, const output = `Error: ${e instanceof Error ? e.message : String(e)}`
lang: args.lang as CliLanguage, await showOutputToUser(context, output)
paths: args.paths, return output
globs: args.globs, }
updateAll: args.dryRun === false, },
}) })
const output = formatReplaceResult(result, args.dryRun !== false)
await showOutputToUser(context, output)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
await showOutputToUser(context, output)
return output
}
},
})
return { ast_grep_search, ast_grep_replace }
}

View File

@ -1,3 +1 @@
import { glob } from "./tools" export { createGlobTools } from "./tools"
export { glob }

View File

@ -1,43 +1,47 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRgFiles } from "./cli" import { runRgFiles } from "./cli"
import { resolveGrepCliWithAutoInstall } from "./constants" import { resolveGrepCliWithAutoInstall } from "./constants"
import { formatGlobResult } from "./utils" import { formatGlobResult } from "./utils"
export const glob: ToolDefinition = tool({ export function createGlobTools(ctx: PluginInput): Record<string, ToolDefinition> {
description: const glob: ToolDefinition = tool({
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " + description:
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " + "Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
"Returns matching file paths sorted by modification time. " + "Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
"Use this tool when you need to find files by name patterns.", "Returns matching file paths sorted by modification time. " +
args: { "Use this tool when you need to find files by name patterns.",
pattern: tool.schema.string().describe("The glob pattern to match files against"), args: {
path: tool.schema pattern: tool.schema.string().describe("The glob pattern to match files against"),
.string() path: tool.schema
.optional() .string()
.describe( .optional()
"The directory to search in. If not specified, the current working directory will be used. " + .describe(
"IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " + "The directory to search in. If not specified, the current working directory will be used. " +
"simply omit it for the default behavior. Must be a valid directory path if provided." "IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " +
), "simply omit it for the default behavior. Must be a valid directory path if provided."
}, ),
execute: async (args) => { },
try { execute: async (args) => {
const cli = await resolveGrepCliWithAutoInstall() try {
// Use process.cwd() as the default search path when no path is provided const cli = await resolveGrepCliWithAutoInstall()
const searchPath = args.path ?? process.cwd() const searchPath = args.path ?? ctx.directory
const paths = [searchPath] const paths = [searchPath]
const result = await runRgFiles( const result = await runRgFiles(
{ {
pattern: args.pattern, pattern: args.pattern,
paths, paths,
}, },
cli cli
) )
return formatGlobResult(result) return formatGlobResult(result)
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` return `Error: ${e instanceof Error ? e.message : String(e)}`
} }
}, },
}) })
return { glob }
}

View File

@ -1,3 +1 @@
import { grep } from "./tools" export { createGrepTools } from "./tools"
export { grep }

View File

@ -1,42 +1,46 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRg } from "./cli" import { runRg } from "./cli"
import { formatGrepResult } from "./utils" import { formatGrepResult } from "./utils"
export const grep: ToolDefinition = tool({ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
description: const grep: ToolDefinition = tool({
"Fast content search tool with safety limits (60s timeout, 10MB output). " + description:
"Searches file contents using regular expressions. " + "Fast content search tool with safety limits (60s timeout, 10MB output). " +
"Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). " + "Searches file contents using regular expressions. " +
"Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). " + "Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). " +
"Returns file paths with matches sorted by modification time.", "Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). " +
args: { "Returns file paths with matches sorted by modification time.",
pattern: tool.schema.string().describe("The regex pattern to search for in file contents"), args: {
include: tool.schema pattern: tool.schema.string().describe("The regex pattern to search for in file contents"),
.string() include: tool.schema
.optional() .string()
.describe("File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"), .optional()
path: tool.schema .describe("File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"),
.string() path: tool.schema
.optional() .string()
.describe("The directory to search in. Defaults to the current working directory."), .optional()
}, .describe("The directory to search in. Defaults to the current working directory."),
execute: async (args) => { },
try { execute: async (args) => {
const globs = args.include ? [args.include] : undefined try {
// Use process.cwd() as the default search path when no path is provided const globs = args.include ? [args.include] : undefined
const searchPath = args.path ?? process.cwd() const searchPath = args.path ?? ctx.directory
const paths = [searchPath] const paths = [searchPath]
const result = await runRg({ const result = await runRg({
pattern: args.pattern, pattern: args.pattern,
paths, paths,
globs, globs,
context: 0, context: 0,
}) })
return formatGrepResult(result) return formatGrepResult(result)
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` return `Error: ${e instanceof Error ? e.message : String(e)}`
} }
}, },
}) })
return { grep }
}

View File

@ -10,21 +10,11 @@ import {
export { lspManager } export { lspManager }
import { export { createAstGrepTools } from "./ast-grep"
ast_grep_search, export { createGrepTools } from "./grep"
ast_grep_replace, export { createGlobTools } from "./glob"
} from "./ast-grep"
import { grep } from "./grep"
import { glob } from "./glob"
export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand" export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand"
export { createSessionManagerTools } from "./session-manager"
import {
session_list,
session_read,
session_search,
session_info,
} from "./session-manager"
export { sessionExists } from "./session-manager/storage" export { sessionExists } from "./session-manager/storage"
@ -70,12 +60,4 @@ export const builtinTools: Record<string, ToolDefinition> = {
lsp_diagnostics, lsp_diagnostics,
lsp_prepare_rename, lsp_prepare_rename,
lsp_rename, lsp_rename,
ast_grep_search,
ast_grep_replace,
grep,
glob,
session_list,
session_read,
session_search,
session_info,
} }

View File

@ -1,3 +1,3 @@
export * from "./tools" export { createSessionManagerTools } from "./tools"
export * from "./types" export * from "./types"
export * from "./constants" export * from "./constants"

View File

@ -1,3 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { import {
SESSION_LIST_DESCRIPTION, SESSION_LIST_DESCRIPTION,
@ -26,121 +27,125 @@ function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Pro
]) ])
} }
export const session_list: ToolDefinition = tool({ export function createSessionManagerTools(ctx: PluginInput): Record<string, ToolDefinition> {
description: SESSION_LIST_DESCRIPTION, const session_list: ToolDefinition = tool({
args: { description: SESSION_LIST_DESCRIPTION,
limit: tool.schema.number().optional().describe("Maximum number of sessions to return"), args: {
from_date: tool.schema.string().optional().describe("Filter sessions from this date (ISO 8601 format)"), limit: tool.schema.number().optional().describe("Maximum number of sessions to return"),
to_date: tool.schema.string().optional().describe("Filter sessions until this date (ISO 8601 format)"), from_date: tool.schema.string().optional().describe("Filter sessions from this date (ISO 8601 format)"),
project_path: tool.schema.string().optional().describe("Filter sessions by project path (default: current working directory)"), to_date: tool.schema.string().optional().describe("Filter sessions until this date (ISO 8601 format)"),
}, project_path: tool.schema.string().optional().describe("Filter sessions by project path (default: current working directory)"),
execute: async (args: SessionListArgs, _context) => { },
try { execute: async (args: SessionListArgs, _context) => {
const directory = args.project_path ?? process.cwd() try {
let sessions = await getMainSessions({ directory }) const directory = args.project_path ?? ctx.directory
let sessionIDs = sessions.map((s) => s.id) let sessions = await getMainSessions({ directory })
let sessionIDs = sessions.map((s) => s.id)
if (args.from_date || args.to_date) { if (args.from_date || args.to_date) {
sessionIDs = await filterSessionsByDate(sessionIDs, args.from_date, args.to_date) sessionIDs = await filterSessionsByDate(sessionIDs, args.from_date, args.to_date)
}
if (args.limit && args.limit > 0) {
sessionIDs = sessionIDs.slice(0, args.limit)
}
return await formatSessionList(sessionIDs)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_read: ToolDefinition = tool({
description: SESSION_READ_DESCRIPTION,
args: {
session_id: tool.schema.string().describe("Session ID to read"),
include_todos: tool.schema.boolean().optional().describe("Include todo list if available (default: false)"),
include_transcript: tool.schema.boolean().optional().describe("Include transcript log if available (default: false)"),
limit: tool.schema.number().optional().describe("Maximum number of messages to return (default: all)"),
},
execute: async (args: SessionReadArgs, _context) => {
try {
if (!sessionExists(args.session_id)) {
return `Session not found: ${args.session_id}`
}
let messages = await readSessionMessages(args.session_id)
if (args.limit && args.limit > 0) {
messages = messages.slice(0, args.limit)
}
const todos = args.include_todos ? await readSessionTodos(args.session_id) : undefined
return formatSessionMessages(messages, args.include_todos, todos)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_search: ToolDefinition = tool({
description: SESSION_SEARCH_DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query string"),
session_id: tool.schema.string().optional().describe("Search within specific session only (default: all sessions)"),
case_sensitive: tool.schema.boolean().optional().describe("Case-sensitive search (default: false)"),
limit: tool.schema.number().optional().describe("Maximum number of results to return (default: 20)"),
},
execute: async (args: SessionSearchArgs, _context) => {
try {
const resultLimit = args.limit && args.limit > 0 ? args.limit : 20
const searchOperation = async (): Promise<SearchResult[]> => {
if (args.session_id) {
return searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
} }
const allSessions = await getAllSessions() if (args.limit && args.limit > 0) {
const sessionsToScan = allSessions.slice(0, MAX_SESSIONS_TO_SCAN) sessionIDs = sessionIDs.slice(0, args.limit)
const allResults: SearchResult[] = []
for (const sid of sessionsToScan) {
if (allResults.length >= resultLimit) break
const remaining = resultLimit - allResults.length
const sessionResults = await searchInSession(sid, args.query, args.case_sensitive, remaining)
allResults.push(...sessionResults)
} }
return allResults.slice(0, resultLimit) return await formatSessionList(sessionIDs)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
} }
},
})
const results = await withTimeout(searchOperation(), SEARCH_TIMEOUT_MS, "Search") const session_read: ToolDefinition = tool({
description: SESSION_READ_DESCRIPTION,
args: {
session_id: tool.schema.string().describe("Session ID to read"),
include_todos: tool.schema.boolean().optional().describe("Include todo list if available (default: false)"),
include_transcript: tool.schema.boolean().optional().describe("Include transcript log if available (default: false)"),
limit: tool.schema.number().optional().describe("Maximum number of messages to return (default: all)"),
},
execute: async (args: SessionReadArgs, _context) => {
try {
if (!sessionExists(args.session_id)) {
return `Session not found: ${args.session_id}`
}
return formatSearchResults(results) let messages = await readSessionMessages(args.session_id)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_info: ToolDefinition = tool({ if (args.limit && args.limit > 0) {
description: SESSION_INFO_DESCRIPTION, messages = messages.slice(0, args.limit)
args: { }
session_id: tool.schema.string().describe("Session ID to inspect"),
},
execute: async (args: SessionInfoArgs, _context) => {
try {
const info = await getSessionInfo(args.session_id)
if (!info) { const todos = args.include_todos ? await readSessionTodos(args.session_id) : undefined
return `Session not found: ${args.session_id}`
return formatSessionMessages(messages, args.include_todos, todos)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
} }
},
})
return formatSessionInfo(info) const session_search: ToolDefinition = tool({
} catch (e) { description: SESSION_SEARCH_DESCRIPTION,
return `Error: ${e instanceof Error ? e.message : String(e)}` args: {
} query: tool.schema.string().describe("Search query string"),
}, session_id: tool.schema.string().optional().describe("Search within specific session only (default: all sessions)"),
}) case_sensitive: tool.schema.boolean().optional().describe("Case-sensitive search (default: false)"),
limit: tool.schema.number().optional().describe("Maximum number of results to return (default: 20)"),
},
execute: async (args: SessionSearchArgs, _context) => {
try {
const resultLimit = args.limit && args.limit > 0 ? args.limit : 20
const searchOperation = async (): Promise<SearchResult[]> => {
if (args.session_id) {
return searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
}
const allSessions = await getAllSessions()
const sessionsToScan = allSessions.slice(0, MAX_SESSIONS_TO_SCAN)
const allResults: SearchResult[] = []
for (const sid of sessionsToScan) {
if (allResults.length >= resultLimit) break
const remaining = resultLimit - allResults.length
const sessionResults = await searchInSession(sid, args.query, args.case_sensitive, remaining)
allResults.push(...sessionResults)
}
return allResults.slice(0, resultLimit)
}
const results = await withTimeout(searchOperation(), SEARCH_TIMEOUT_MS, "Search")
return formatSearchResults(results)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
const session_info: ToolDefinition = tool({
description: SESSION_INFO_DESCRIPTION,
args: {
session_id: tool.schema.string().describe("Session ID to inspect"),
},
execute: async (args: SessionInfoArgs, _context) => {
try {
const info = await getSessionInfo(args.session_id)
if (!info) {
return `Session not found: ${args.session_id}`
}
return formatSessionInfo(info)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
return { session_list, session_read, session_search, session_info }
}