diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 61bc609bb7c..37994328f8e 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -539,10 +539,14 @@ export const RunCommand = cmd({
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
+ const patterns = permission.patterns
+ const desc =
+ permission.permission === ".opencode"
+ ? `.opencode location: ${patterns.map((x) => path.resolve(x)).join(", ")}`
+ : patterns.join(", ")
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
- UI.Style.TEXT_NORMAL +
- `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
+ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${desc}); auto-rejecting`,
)
await sdk.permission.reply({
requestID: permission.id,
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index a50cd96fc84..6b1cf98f108 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -368,7 +368,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
- const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
+ const patterns = (props.request.patterns ?? []).filter((p: unknown): p is string => typeof p === "string")
return {
icon: "←",
@@ -378,7 +378,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
Patterns
- {(p) => {"- " + p}}
+ {(p: string) => {"- " + p}}
@@ -386,6 +386,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}
+ if (permission === ".opencode") {
+ const meta = props.request.metadata ?? {}
+ const raw = typeof meta["path"] === "string" ? meta["path"] : props.request.patterns?.[0]
+ const dir = raw ? path.resolve(raw) : ""
+ return {
+ icon: "⚙",
+ title: "Allow .opencode config",
+ body: (
+
+ {dir}
+
+ ),
+ }
+ }
+
if (permission === "doom_loop") {
return {
icon: "⟳",
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 4f77920cc98..1a3077c6b54 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -783,6 +783,7 @@ export namespace SessionPrompt {
for (const item of await ToolRegistry.tools(
{ modelID: input.model.api.id, providerID: input.model.providerID },
input.agent,
+ input.session.id,
)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index c6d7fbc1e4b..049a64ce1dc 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -30,36 +30,104 @@ import { Truncate } from "./truncation"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
+import { PermissionNext } from "@/permission/next"
+import { Global } from "@/global"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
export const state = Instance.state(async () => {
- const custom = [] as Tool.Info[]
-
- const matches = await Config.directories().then((dirs) =>
- dirs.flatMap((dir) =>
- Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
- ),
- )
- if (matches.length) await Config.waitForDependencies()
- for (const match of matches) {
- const namespace = path.basename(match, path.extname(match))
- const mod = await import(pathToFileURL(match).href)
- for (const [id, def] of Object.entries(mod)) {
- custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
- }
+ return {
+ custom: [] as Tool.Info[],
+ loaded: false,
+ loading: undefined as Promise | undefined,
}
+ })
- const plugins = await Plugin.list()
- for (const plugin of plugins) {
- for (const [id, def] of Object.entries(plugin.tool ?? {})) {
- custom.push(fromPlugin(id, def))
- }
- }
+ function project(dir: string) {
+ const root = path.resolve(Instance.worktree)
+ const cur = path.resolve(dir)
+ if (!dir.endsWith(".opencode")) return false
+ if (!cur.startsWith(root + path.sep)) return false
+ if (dir === Flag.OPENCODE_CONFIG_DIR) return false
+ if (dir === Global.Path.config) return false
+ return cur !== path.join(Global.Path.home, ".opencode")
+ }
- return { custom }
- })
+ async function load(sessionID?: string) {
+ const s = await state()
+ if (s.loaded) return
+ if (s.loading) return s.loading
+
+ s.loading = Config.directories()
+ .then((dirs) =>
+ dirs.flatMap((dir) =>
+ Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }).map(
+ (file) => ({
+ dir,
+ file,
+ }),
+ ),
+ ),
+ )
+ .then(async (entries) => {
+ if (entries.length) await Config.waitForDependencies()
+ const gate = new Map()
+
+ for (const item of entries) {
+ if (project(item.dir)) {
+ const ok = gate.get(item.dir)
+ if (ok === false) continue
+ if (ok !== true) {
+ if (!sessionID) {
+ log.warn("skipping project custom tools", {
+ path: item.dir,
+ reason: "no active session for permission prompt",
+ })
+ gate.set(item.dir, false)
+ continue
+ }
+ const err = await PermissionNext.ask({
+ permission: ".opencode",
+ patterns: [item.dir],
+ always: [item.dir],
+ sessionID,
+ metadata: {
+ path: item.dir,
+ file: item.file,
+ },
+ ruleset: [],
+ }).catch((x) => x)
+ if (err instanceof Error) {
+ log.warn("project custom tools denied", { path: item.dir })
+ gate.set(item.dir, false)
+ continue
+ }
+ gate.set(item.dir, true)
+ }
+ }
+
+ const namespace = path.basename(item.file, path.extname(item.file))
+ const mod = await import(pathToFileURL(item.file).href)
+ for (const [id, def] of Object.entries(mod)) {
+ s.custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
+ }
+ }
+
+ const plugins = await Plugin.list()
+ for (const plugin of plugins) {
+ for (const [id, def] of Object.entries(plugin.tool ?? {})) {
+ s.custom.push(fromPlugin(id, def))
+ }
+ }
+ s.loaded = true
+ })
+ .finally(() => {
+ s.loading = undefined
+ })
+
+ return s.loading
+ }
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
@@ -86,6 +154,7 @@ export namespace ToolRegistry {
}
export async function register(tool: Tool.Info) {
+ await load()
const { custom } = await state()
const idx = custom.findIndex((t) => t.id === tool.id)
if (idx >= 0) {
@@ -95,7 +164,8 @@ export namespace ToolRegistry {
custom.push(tool)
}
- async function all(): Promise {
+ async function all(sessionID?: string): Promise {
+ await load(sessionID)
const custom = await state().then((x) => x.custom)
const config = await Config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
@@ -134,8 +204,9 @@ export namespace ToolRegistry {
modelID: string
},
agent?: Agent.Info,
+ sessionID?: string,
) {
- const tools = await all()
+ const tools = await all(sessionID)
const result = await Promise.all(
tools
.filter((t) => {