Dax Raad vor 3 Monaten
Ursprung
Commit
8ca4343053

+ 2 - 0
package.json

@@ -78,6 +78,8 @@
     "url": "https://github.com/sst/opencode"
   },
   "license": "MIT",
+  "randomField": "hello from claude",
+  "anotherRandomField": "xkcd-927-compliance-level",
   "prettier": {
     "semi": false,
     "printWidth": 120

+ 1 - 1
packages/opencode/src/cli/cmd/run.ts

@@ -206,7 +206,7 @@ export const RunCommand = cmd({
             const permission = event.properties
             if (permission.sessionID !== sessionID) continue
             const result = await select({
-              message: `Permission required to run: ${permission.message}`,
+              message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
               options: [
                 { value: "once", label: "Allow once" },
                 { value: "always", label: "Always allow: " + permission.always.join(", ") },

+ 3 - 5
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1390,7 +1390,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
     },
     get permission() {
       const permissions = sync.data.permission[props.message.sessionID] ?? []
-      const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
+      const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID)
       return permissions[permissionIndex]
     },
     get tool() {
@@ -1483,12 +1483,13 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child
   const sync = useSync()
 
   const permission = createMemo(() => {
-    const callID = sync.data.permission[ctx.sessionID]?.at(0)?.callID
+    const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
     if (!callID) return false
     return callID === props.part.callID
   })
 
   const fg = createMemo(() => {
+    if (permission()) return theme.warning
     if (props.complete) return theme.textMuted
     return theme.text
   })
@@ -1528,9 +1529,6 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child
         <Show fallback={<>~ {props.pending}</>} when={props.complete}>
           <span style={{ bold: true }}>{props.icon}</span> {props.children}
         </Show>
-        <Show when={permission()}>
-          ·<span style={{ fg: theme.warning }}> Permission required</span>
-        </Show>
       </text>
       <Show when={error() && !denied()}>
         <text fg={theme.error}>{error()}</text>

+ 85 - 33
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -8,6 +8,7 @@ import { SplitBorder } from "../../component/border"
 import { useSync } from "../../context/sync"
 import path from "path"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import { Locale } from "@/util/locale"
 
 function normalizePath(input?: string) {
   if (!input) return ""
@@ -30,9 +31,8 @@ function EditBody(props: { request: PermissionRequest }) {
   const sync = useSync()
   const dimensions = useTerminalDimensions()
 
-  const metadata = props.request.metadata as { filepath?: string; diff?: string }
-  const filepath = createMemo(() => metadata.filepath ?? "")
-  const diff = createMemo(() => metadata.diff ?? "")
+  const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
+  const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
 
   const view = createMemo(() => {
     const diffStyle = sync.data.config.tui?.diff_style
@@ -44,7 +44,7 @@ function EditBody(props: { request: PermissionRequest }) {
 
   return (
     <box flexDirection="column" gap={1}>
-      <box flexDirection="row" gap={1}>
+      <box flexDirection="row" gap={1} paddingLeft={1}>
         <text fg={theme.textMuted}>{"→"}</text>
         <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
       </box>
@@ -75,32 +75,50 @@ function EditBody(props: { request: PermissionRequest }) {
   )
 }
 
-function TextBody(props: { text: string }) {
+function TextBody(props: { title: string; description?: string; icon: string }) {
   const { theme } = useTheme()
   return (
-    <box flexDirection="row" gap={1}>
-      <text fg={theme.textMuted} flexShrink={0}>
-        {"→"}
-      </text>
-      <text fg={theme.textMuted}>{props.text}</text>
-    </box>
+    <>
+      <box flexDirection="row" gap={1} paddingLeft={1}>
+        <text fg={theme.textMuted} flexShrink={0}>
+          {props.icon}
+        </text>
+        <text fg={theme.textMuted}>{props.title}</text>
+      </box>
+      <Show when={props.description}>
+        <box paddingLeft={1}>
+          <text fg={theme.text}>{props.description}</text>
+        </box>
+      </Show>
+    </>
   )
 }
 
 export function PermissionPrompt(props: { request: PermissionRequest }) {
   const sdk = useSDK()
+  const sync = useSync()
   const [store, setStore] = createStore({
     always: false,
   })
 
-  const metadata = props.request.metadata as { filepath?: string }
+  const input = createMemo(() => {
+    const tool = props.request.tool
+    if (!tool) return {}
+    const parts = sync.data.part[tool.messageID] ?? []
+    for (const part of parts) {
+      if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
+        return part.state.input ?? {}
+      }
+    }
+    return {}
+  })
 
   return (
     <Switch>
       <Match when={store.always}>
         <Prompt
           title="Always allow"
-          body={<TextBody text={props.request.always.join("\n")} />}
+          body={<TextBody icon="→" title={props.request.always.join("\n")} />}
           options={{ confirm: "Confirm", cancel: "Cancel" }}
           onSelect={(option) => {
             if (option === "cancel") {
@@ -114,27 +132,61 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
           }}
         />
       </Match>
-      <Match when={props.request.permission === "edit" && !store.always}>
-        <Prompt
-          title="Permission required"
-          body={<EditBody request={props.request} />}
-          options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
-          onSelect={(option) => {
-            if (option === "always") {
-              setStore("always", true)
-              return
-            }
-            sdk.client.permission.reply({
-              reply: option as "once" | "reject",
-              requestID: props.request.id,
-            })
-          }}
-        />
-      </Match>
       <Match when={!store.always}>
         <Prompt
           title="Permission required"
-          body={<TextBody text={props.request.message} />}
+          body={
+            <Switch>
+              <Match when={props.request.permission === "edit"}>
+                <EditBody request={props.request} />
+              </Match>
+              <Match when={props.request.permission === "read"}>
+                <TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
+              </Match>
+              <Match when={props.request.permission === "glob"}>
+                <TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
+              </Match>
+              <Match when={props.request.permission === "grep"}>
+                <TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
+              </Match>
+              <Match when={props.request.permission === "list"}>
+                <TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
+              </Match>
+              <Match when={props.request.permission === "bash"}>
+                <TextBody
+                  icon="#"
+                  title={(input().description as string) ?? ""}
+                  description={("$ " + input().command) as string}
+                />
+              </Match>
+              <Match when={props.request.permission === "task"}>
+                <TextBody
+                  icon="◉"
+                  title={
+                    `${Locale.titlecase(input().subagent_type as string)} Task "` + (input().description ?? "") + `"`
+                  }
+                />
+              </Match>
+              <Match when={props.request.permission === "webfetch"}>
+                <TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
+              </Match>
+              <Match when={props.request.permission === "websearch"}>
+                <TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
+              </Match>
+              <Match when={props.request.permission === "codesearch"}>
+                <TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
+              </Match>
+              <Match when={props.request.permission === "external_directory"}>
+                <TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
+              </Match>
+              <Match when={props.request.permission === "doom_loop"}>
+                <TextBody icon="⟳" title="Continue after repeated failures" />
+              </Match>
+              <Match when={true}>
+                <TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
+              </Match>
+            </Switch>
+          }
           options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
           onSelect={(option) => {
             if (option === "always") {
@@ -192,8 +244,8 @@ function Prompt<const T extends Record<string, string>>(props: {
       borderColor={theme.warning}
       customBorderChars={SplitBorder.customBorderChars}
     >
-      <box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
-        <box flexDirection="row" gap={1}>
+      <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
+        <box flexDirection="row" gap={1} paddingLeft={1}>
           <text fg={theme.warning}>{"△"}</text>
           <text fg={theme.text}>{props.title}</text>
         </box>

+ 6 - 2
packages/opencode/src/permission/next.ts

@@ -56,13 +56,17 @@ export namespace PermissionNext {
   export const Request = z
     .object({
       id: Identifier.schema("permission"),
-      callID: z.string().optional(),
       sessionID: Identifier.schema("session"),
       permission: z.string(),
       patterns: z.string().array(),
-      message: z.string(),
       metadata: z.record(z.string(), z.any()),
       always: z.string().array(),
+      tool: z
+        .object({
+          messageID: z.string(),
+          callID: z.string(),
+        })
+        .optional(),
     })
     .meta({
       ref: "PermissionRequest",

+ 0 - 1
packages/opencode/src/session/processor.ts

@@ -157,7 +157,6 @@ export namespace SessionProcessor {
                       await PermissionNext.ask({
                         permission: "doom_loop",
                         patterns: [value.toolName],
-                        message: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
                         sessionID: input.assistantMessage.sessionID,
                         metadata: {
                           tool: value.toolName,

+ 57 - 37
packages/opencode/src/session/prompt.ts

@@ -38,6 +38,8 @@ import { NamedError } from "@opencode-ai/util/error"
 import { fn } from "@/util/fn"
 import { SessionProcessor } from "./processor"
 import { TaskTool } from "@/tool/task"
+import { Tool } from "@/tool/tool"
+import { PermissionNext } from "@/permission/next"
 import { SessionStatus } from "./status"
 import { LLM } from "./llm"
 import { iife } from "@/util/iife"
@@ -349,28 +351,35 @@ export namespace SessionPrompt {
           { args: taskArgs },
         )
         let executionError: Error | undefined
-        const result = await taskTool
-          .execute(taskArgs, {
-            agent: task.agent,
-            messageID: assistantMessage.id,
-            sessionID: sessionID,
-            abort,
-            async metadata(input) {
-              await Session.updatePart({
-                ...part,
-                type: "tool",
-                state: {
-                  ...part.state,
-                  ...input,
-                },
-              } satisfies MessageV2.ToolPart)
-            },
-          })
-          .catch((error) => {
-            executionError = error
-            log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
-            return undefined
-          })
+        const taskAgent = await Agent.get(task.agent)
+        const taskCtx: Tool.Context = {
+          agent: task.agent,
+          messageID: assistantMessage.id,
+          sessionID: sessionID,
+          abort,
+          async metadata(input) {
+            await Session.updatePart({
+              ...part,
+              type: "tool",
+              state: {
+                ...part.state,
+                ...input,
+              },
+            } satisfies MessageV2.ToolPart)
+          },
+          async ask(req) {
+            await PermissionNext.ask({
+              ...req,
+              sessionID: sessionID,
+              ruleset: taskAgent.permission,
+            })
+          },
+        }
+        const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
+          executionError = error
+          log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
+          return undefined
+        })
         await Plugin.trigger(
           "tool.execute.after",
           {
@@ -604,14 +613,14 @@ export namespace SessionPrompt {
               args,
             },
           )
-          const result = await item.execute(args, {
+          const ctx: Tool.Context = {
             sessionID: input.sessionID,
             abort: options.abortSignal!,
             messageID: input.processor.message.id,
             callID: options.toolCallId,
             extra: { model: input.model },
             agent: input.agent.name,
-            metadata: async (val) => {
+            metadata: async (val: { title?: string; metadata?: any }) => {
               const match = input.processor.partFromToolCall(options.toolCallId)
               if (match && match.state.status === "running") {
                 await Session.updatePart({
@@ -628,7 +637,16 @@ export namespace SessionPrompt {
                 })
               }
             },
-          })
+            async ask(req) {
+              await PermissionNext.ask({
+                ...req,
+                sessionID: input.sessionID,
+                tool: { messageID: input.processor.message.id, callID: options.toolCallId },
+                ruleset: input.agent.permission,
+              })
+            },
+          }
+          const result = await item.execute(args, ctx)
           await Plugin.trigger(
             "tool.execute.after",
             {
@@ -826,14 +844,16 @@ export namespace SessionPrompt {
                 await ReadTool.init()
                   .then(async (t) => {
                     const model = await Provider.getModel(info.model.providerID, info.model.modelID)
-                    const result = await t.execute(args, {
+                    const readCtx: Tool.Context = {
                       sessionID: input.sessionID,
                       abort: new AbortController().signal,
                       agent: input.agent!,
                       messageID: info.id,
                       extra: { bypassCwdCheck: true, model },
                       metadata: async () => {},
-                    })
+                      ask: async () => {},
+                    }
+                    const result = await t.execute(args, readCtx)
                     pieces.push({
                       id: Identifier.ascending("part"),
                       messageID: info.id,
@@ -885,16 +905,16 @@ export namespace SessionPrompt {
 
               if (part.mime === "application/x-directory") {
                 const args = { path: filepath }
-                const result = await ListTool.init().then((t) =>
-                  t.execute(args, {
-                    sessionID: input.sessionID,
-                    abort: new AbortController().signal,
-                    agent: input.agent!,
-                    messageID: info.id,
-                    extra: { bypassCwdCheck: true },
-                    metadata: async () => {},
-                  }),
-                )
+                const listCtx: Tool.Context = {
+                  sessionID: input.sessionID,
+                  abort: new AbortController().signal,
+                  agent: input.agent!,
+                  messageID: info.id,
+                  extra: { bypassCwdCheck: true },
+                  metadata: async () => {},
+                  ask: async () => {},
+                }
+                const result = await ListTool.init().then((t) => t.execute(args, listCtx))
                 return [
                   {
                     id: Identifier.ascending("part"),

+ 4 - 15
packages/opencode/src/tool/bash.ts

@@ -6,13 +6,13 @@ import { Log } from "../util/log"
 import { Instance } from "../project/instance"
 import { lazy } from "@/util/lazy"
 import { Language } from "web-tree-sitter"
-import { Agent } from "@/agent/agent"
+
 import { $ } from "bun"
 import { Filesystem } from "@/util/filesystem"
 import { fileURLToPath } from "url"
 import { Flag } from "@/flag/flag.ts"
 import { Shell } from "@/shell/shell"
-import { PermissionNext } from "@/permission/next"
+
 import { BashArity } from "@/permission/arity"
 
 const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
@@ -80,8 +80,6 @@ export const BashTool = Tool.define("bash", async () => {
       if (!tree) {
         throw new Error("Failed to parse command")
       }
-      const agent = await Agent.get(ctx.agent)
-
       const directories = new Set<string>()
       if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
       const patterns = new Set<string>()
@@ -134,29 +132,20 @@ export const BashTool = Tool.define("bash", async () => {
       }
 
       if (directories.size > 0) {
-        const dirs = Array.from(directories)
-        await PermissionNext.ask({
-          callID: ctx.callID,
+        await ctx.ask({
           permission: "external_directory",
-          message: `Requesting access to external directories: ${dirs.join(", ")}`,
           patterns: Array.from(directories),
           always: Array.from(directories).map((x) => x + "*"),
-          sessionID: ctx.sessionID,
           metadata: {},
-          ruleset: agent.permission,
         })
       }
 
       if (patterns.size > 0) {
-        await PermissionNext.ask({
-          callID: ctx.callID,
+        await ctx.ask({
           permission: "bash",
           patterns: Array.from(patterns),
           always: Array.from(always),
-          sessionID: ctx.sessionID,
-          message: params.command,
           metadata: {},
-          ruleset: agent.permission,
         })
       }
 

+ 1 - 9
packages/opencode/src/tool/codesearch.ts

@@ -1,8 +1,6 @@
 import z from "zod"
 import { Tool } from "./tool"
 import DESCRIPTION from "./codesearch.txt"
-import { PermissionNext } from "@/permission/next"
-import { Agent } from "@/agent/agent"
 
 const API_CONFIG = {
   BASE_URL: "https://mcp.exa.ai",
@@ -52,20 +50,14 @@ export const CodeSearchTool = Tool.define("codesearch", {
       ),
   }),
   async execute(params, ctx) {
-    const agent = await Agent.get(ctx.agent)
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "codesearch",
-      message: "Search code for: " + params.query,
       patterns: [params.query],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         query: params.query,
         tokensNum: params.tokensNum,
       },
-
-      ruleset: agent.permission,
     })
 
     const codeRequest: McpCodeRequest = {

+ 23 - 33
packages/opencode/src/tool/edit.ts

@@ -14,9 +14,7 @@ import { Bus } from "../bus"
 import { FileTime } from "../file/time"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
 import { Snapshot } from "@/snapshot"
-import { PermissionNext } from "@/permission/next"
 
 const MAX_DIAGNOSTICS_PER_FILE = 20
 
@@ -41,24 +39,17 @@ export const EditTool = Tool.define("edit", {
       throw new Error("oldString and newString must be different")
     }
 
-    const agent = await Agent.get(ctx.agent)
-
     const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
     if (!Filesystem.contains(Instance.directory, filePath)) {
       const parentDir = path.dirname(filePath)
-      await PermissionNext.ask({
-        callID: ctx.callID,
+      await ctx.ask({
         permission: "external_directory",
-        message: `Edit file outside working directory: ${filePath}`,
         patterns: [parentDir, path.join(parentDir, "*")],
         always: [parentDir + "/*"],
-        sessionID: ctx.sessionID,
         metadata: {
           filepath: filePath,
           parentDir,
         },
-
-        ruleset: agent.permission,
       })
     }
 
@@ -69,19 +60,14 @@ export const EditTool = Tool.define("edit", {
       if (params.oldString === "") {
         contentNew = params.newString
         diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
-        await PermissionNext.ask({
-          callID: ctx.callID,
+        await ctx.ask({
           permission: "edit",
-          message: "Edit this file: " + path.relative(Instance.directory, filePath),
           patterns: [path.relative(Instance.worktree, filePath)],
           always: ["*"],
-          sessionID: ctx.sessionID,
           metadata: {
             filepath: filePath,
             diff,
           },
-
-          ruleset: agent.permission,
         })
         await Bun.write(filePath, params.newString)
         await Bus.publish(File.Event.Edited, {
@@ -102,18 +88,14 @@ export const EditTool = Tool.define("edit", {
       diff = trimDiff(
         createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
       )
-      await PermissionNext.ask({
+      await ctx.ask({
         permission: "edit",
-        callID: ctx.callID,
-        message: "Edit this file: " + path.relative(Instance.directory, filePath),
         patterns: [path.relative(Instance.worktree, filePath)],
         always: ["*"],
-        sessionID: ctx.sessionID,
         metadata: {
           filepath: filePath,
           diff,
         },
-        ruleset: agent.permission,
       })
 
       await file.write(contentNew)
@@ -127,6 +109,26 @@ export const EditTool = Tool.define("edit", {
       FileTime.read(ctx.sessionID, filePath)
     })
 
+    const filediff: Snapshot.FileDiff = {
+      file: filePath,
+      before: contentOld,
+      after: contentNew,
+      additions: 0,
+      deletions: 0,
+    }
+    for (const change of diffLines(contentOld, contentNew)) {
+      if (change.added) filediff.additions += change.count || 0
+      if (change.removed) filediff.deletions += change.count || 0
+    }
+
+    ctx.metadata({
+      metadata: {
+        diff,
+        filediff,
+        diagnostics: {},
+      },
+    })
+
     let output = ""
     await LSP.touchFile(filePath, true)
     const diagnostics = await LSP.diagnostics()
@@ -140,18 +142,6 @@ export const EditTool = Tool.define("edit", {
       output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
     }
 
-    const filediff: Snapshot.FileDiff = {
-      file: filePath,
-      before: contentOld,
-      after: contentNew,
-      additions: 0,
-      deletions: 0,
-    }
-    for (const change of diffLines(contentOld, contentNew)) {
-      if (change.added) filediff.additions += change.count || 0
-      if (change.removed) filediff.deletions += change.count || 0
-    }
-
     return {
       metadata: {
         diagnostics,

+ 1 - 9
packages/opencode/src/tool/glob.ts

@@ -4,8 +4,6 @@ import { Tool } from "./tool"
 import DESCRIPTION from "./glob.txt"
 import { Ripgrep } from "../file/ripgrep"
 import { Instance } from "../project/instance"
-import { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
 
 export const GlobTool = Tool.define("glob", {
   description: DESCRIPTION,
@@ -19,20 +17,14 @@ export const GlobTool = Tool.define("glob", {
       ),
   }),
   async execute(params, ctx) {
-    const agent = await Agent.get(ctx.agent)
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "glob",
-      message: `Glob search: ${params.pattern}`,
       patterns: [params.pattern],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         pattern: params.pattern,
         path: params.path,
       },
-
-      ruleset: agent.permission,
     })
 
     let search = params.path ?? Instance.directory

+ 1 - 9
packages/opencode/src/tool/grep.ts

@@ -4,8 +4,6 @@ import { Ripgrep } from "../file/ripgrep"
 
 import DESCRIPTION from "./grep.txt"
 import { Instance } from "../project/instance"
-import { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
 
 const MAX_LINE_LENGTH = 2000
 
@@ -21,21 +19,15 @@ export const GrepTool = Tool.define("grep", {
       throw new Error("pattern is required")
     }
 
-    const agent = await Agent.get(ctx.agent)
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "grep",
-      message: `Grep search: ${params.pattern}`,
       patterns: [params.pattern],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         pattern: params.pattern,
         path: params.path,
         include: params.include,
       },
-
-      ruleset: agent.permission,
     })
 
     const searchPath = params.path || Instance.directory

+ 1 - 9
packages/opencode/src/tool/ls.ts

@@ -4,8 +4,6 @@ import * as path from "path"
 import DESCRIPTION from "./ls.txt"
 import { Instance } from "../project/instance"
 import { Ripgrep } from "../file/ripgrep"
-import { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
 
 export const IGNORE_PATTERNS = [
   "node_modules/",
@@ -45,19 +43,13 @@ export const ListTool = Tool.define("list", {
   async execute(params, ctx) {
     const searchPath = path.resolve(Instance.directory, params.path || ".")
 
-    const agent = await Agent.get(ctx.agent)
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "list",
-      message: `List directory: ${searchPath}`,
       patterns: [searchPath],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         path: searchPath,
       },
-
-      ruleset: agent.permission,
     })
 
     const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])

+ 2 - 15
packages/opencode/src/tool/patch.ts

@@ -6,11 +6,9 @@ import { FileTime } from "../file/time"
 import { Bus } from "../bus"
 import { FileWatcher } from "../file/watcher"
 import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
 import { Patch } from "../patch"
 import { Filesystem } from "../util/filesystem"
 import { createTwoFilesPatch } from "diff"
-import { PermissionNext } from "@/permission/next"
 
 const PatchParams = z.object({
   patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -39,7 +37,6 @@ export const PatchTool = Tool.define("patch", {
     }
 
     // Validate file paths and check permissions
-    const agent = await Agent.get(ctx.agent)
     const fileChanges: Array<{
       filePath: string
       oldContent: string
@@ -55,19 +52,14 @@ export const PatchTool = Tool.define("patch", {
 
       if (!Filesystem.contains(Instance.directory, filePath)) {
         const parentDir = path.dirname(filePath)
-        await PermissionNext.ask({
-          callID: ctx.callID,
+        await ctx.ask({
           permission: "external_directory",
-          message: `Patch file outside working directory: ${filePath}`,
           patterns: [parentDir, path.join(parentDir, "*")],
           always: [parentDir + "/*"],
-          sessionID: ctx.sessionID,
           metadata: {
             filepath: filePath,
             parentDir,
           },
-
-          ruleset: agent.permission,
         })
       }
 
@@ -141,18 +133,13 @@ export const PatchTool = Tool.define("patch", {
     }
 
     // Check permissions if needed
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "edit",
-      message: `Apply patch to ${fileChanges.length} files`,
       patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         diff: totalDiff,
       },
-
-      ruleset: agent.permission,
     })
 
     // Apply the changes

+ 2 - 15
packages/opencode/src/tool/read.ts

@@ -8,9 +8,7 @@ import DESCRIPTION from "./read.txt"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
 import { Identifier } from "../id/id"
-import { Agent } from "@/agent/agent"
 import { iife } from "@/util/iife"
-import { PermissionNext } from "@/permission/next"
 
 const DEFAULT_READ_LIMIT = 2000
 const MAX_LINE_LENGTH = 2000
@@ -28,36 +26,25 @@ export const ReadTool = Tool.define("read", {
       filepath = path.join(process.cwd(), filepath)
     }
     const title = path.relative(Instance.worktree, filepath)
-    const agent = await Agent.get(ctx.agent)
 
     if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
       const parentDir = path.dirname(filepath)
-      await PermissionNext.ask({
-        callID: ctx.callID,
+      await ctx.ask({
         permission: "external_directory",
-        message: `Access file outside working directory: ${filepath}`,
         patterns: [parentDir],
         always: [parentDir + "/*"],
-        sessionID: ctx.sessionID,
         metadata: {
           filepath,
           parentDir,
         },
-
-        ruleset: agent.permission,
       })
     }
 
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "read",
-      message: `Read file ${filepath}`,
       patterns: [filepath],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {},
-
-      ruleset: agent.permission,
     })
 
     const block = iife(() => {

+ 1 - 9
packages/opencode/src/tool/skill.ts

@@ -2,9 +2,7 @@ import path from "path"
 import z from "zod"
 import { Tool } from "./tool"
 import { Skill } from "../skill"
-import { Agent } from "../agent/agent"
 import { ConfigMarkdown } from "../config/markdown"
-import { PermissionNext } from "@/permission/next"
 
 export const SkillTool = Tool.define("skill", async () => {
   const skills = await Skill.all()
@@ -46,8 +44,6 @@ export const SkillTool = Tool.define("skill", async () => {
         .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
     }),
     async execute(params, ctx) {
-      const agent = await Agent.get(ctx.agent)
-
       const skill = await Skill.get(params.name)
 
       if (!skill) {
@@ -55,15 +51,11 @@ export const SkillTool = Tool.define("skill", async () => {
         throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
       }
 
-      await PermissionNext.ask({
-        callID: ctx.callID,
+      await ctx.ask({
         permission: "skill",
         patterns: [params.name],
         always: [params.name],
-        sessionID: ctx.sessionID,
-        message: `Activate skill ${params.name}`,
         metadata: {},
-        ruleset: agent.permission,
       })
       // Load and parse skill content
       const parsed = await ConfigMarkdown.parse(skill.location)

+ 1 - 7
packages/opencode/src/tool/task.ts

@@ -10,7 +10,6 @@ import { SessionPrompt } from "../session/prompt"
 import { iife } from "@/util/iife"
 import { defer } from "@/util/defer"
 import { Config } from "../config/config"
-import { PermissionNext } from "@/permission/next"
 
 export const TaskTool = Tool.define("task", async () => {
   const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
@@ -30,19 +29,14 @@ export const TaskTool = Tool.define("task", async () => {
       command: z.string().describe("The command that triggered this task").optional(),
     }),
     async execute(params, ctx) {
-      const callingAgent = await Agent.get(ctx.agent)
-      await PermissionNext.ask({
-        callID: ctx.callID,
+      await ctx.ask({
         permission: "task",
-        message: `Launch task: ${params.description}`,
         patterns: [params.subagent_type],
         always: ["*"],
-        sessionID: ctx.sessionID,
         metadata: {
           description: params.description,
           subagent_type: params.subagent_type,
         },
-        ruleset: callingAgent.permission,
       })
 
       const agent = await Agent.get(params.subagent_type)

+ 2 - 0
packages/opencode/src/tool/tool.ts

@@ -1,6 +1,7 @@
 import z from "zod"
 import type { MessageV2 } from "../session/message-v2"
 import type { Agent } from "../agent/agent"
+import type { PermissionNext } from "../permission/next"
 
 export namespace Tool {
   interface Metadata {
@@ -19,6 +20,7 @@ export namespace Tool {
     callID?: string
     extra?: { [key: string]: any }
     metadata(input: { title?: string; metadata?: M }): void
+    ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
   }
   export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
     id: string

+ 1 - 9
packages/opencode/src/tool/webfetch.ts

@@ -2,8 +2,6 @@ import z from "zod"
 import { Tool } from "./tool"
 import TurndownService from "turndown"
 import DESCRIPTION from "./webfetch.txt"
-import { PermissionNext } from "@/permission/next"
-import { Agent } from "@/agent/agent"
 
 const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
 const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -25,21 +23,15 @@ export const WebFetchTool = Tool.define("webfetch", {
       throw new Error("URL must start with http:// or https://")
     }
 
-    const agent = await Agent.get(ctx.agent)
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "webfetch",
-      message: "Fetch content from: " + params.url,
       patterns: [params.url],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         url: params.url,
         format: params.format,
         timeout: params.timeout,
       },
-
-      ruleset: agent.permission,
     })
 
     const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)

+ 1 - 9
packages/opencode/src/tool/websearch.ts

@@ -1,8 +1,6 @@
 import z from "zod"
 import { Tool } from "./tool"
 import DESCRIPTION from "./websearch.txt"
-import { PermissionNext } from "@/permission/next"
-import { Agent } from "@/agent/agent"
 
 const API_CONFIG = {
   BASE_URL: "https://mcp.exa.ai",
@@ -59,14 +57,10 @@ export const WebSearchTool = Tool.define("websearch", {
       .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
   }),
   async execute(params, ctx) {
-    const agent = await Agent.get(ctx.agent)
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "websearch",
-      message: "Search web for: " + params.query,
       patterns: [params.query],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {
         query: params.query,
         numResults: params.numResults,
@@ -74,8 +68,6 @@ export const WebSearchTool = Tool.define("websearch", {
         type: params.type,
         contextMaxCharacters: params.contextMaxCharacters,
       },
-
-      ruleset: agent.permission,
     })
 
     const searchRequest: McpSearchRequest = {

+ 2 - 35
packages/opencode/src/tool/write.ts

@@ -8,8 +8,6 @@ import { File } from "../file"
 import { FileTime } from "../file/time"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
-import { PermissionNext } from "@/permission/next"
 
 const MAX_DIAGNOSTICS_PER_FILE = 20
 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -21,37 +19,11 @@ export const WriteTool = Tool.define("write", {
     filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
   }),
   async execute(params, ctx) {
-    const agent = await Agent.get(ctx.agent)
-
     const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
     /* TODO
     if (!Filesystem.contains(Instance.directory, filepath)) {
       const parentDir = path.dirname(filepath)
-      if (agent.permission.external_directory === "ask") {
-        await Permission.ask({
-          type: "external_directory",
-          pattern: [parentDir, path.join(parentDir, "*")],
-          sessionID: ctx.sessionID,
-          messageID: ctx.messageID,
-          callID: ctx.callID,
-          title: `Write file outside working directory: ${filepath}`,
-          metadata: {
-            filepath,
-            parentDir,
-          },
-        })
-      } else if (agent.permission.external_directory === "deny") {
-        throw new Permission.RejectedError(
-          ctx.sessionID,
-          "external_directory",
-          ctx.callID,
-          {
-            filepath: filepath,
-            parentDir,
-          },
-          `File ${filepath} is not in the current working directory`,
-        )
-      }
+      ...
     }
     */
 
@@ -59,16 +31,11 @@ export const WriteTool = Tool.define("write", {
     const exists = await file.exists()
     if (exists) await FileTime.assert(ctx.sessionID, filepath)
 
-    await PermissionNext.ask({
-      callID: ctx.callID,
+    await ctx.ask({
       permission: "edit",
-      message: `Create new file ${path.relative(Instance.directory, filepath)}`,
       patterns: [path.relative(Instance.worktree, filepath)],
       always: ["*"],
-      sessionID: ctx.sessionID,
       metadata: {},
-
-      ruleset: agent.permission,
     })
 
     await Bun.write(filepath, params.content)

+ 0 - 11
packages/opencode/test/permission/next.test.ts

@@ -428,7 +428,6 @@ test("ask - resolves immediately when action is allow", async () => {
         sessionID: "session_test",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls command",
         metadata: {},
         always: [],
         ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
@@ -448,7 +447,6 @@ test("ask - throws RejectedError when action is deny", async () => {
           sessionID: "session_test",
           permission: "bash",
           patterns: ["rm -rf /"],
-          message: "Run dangerous command",
           metadata: {},
           always: [],
           ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
@@ -467,7 +465,6 @@ test("ask - returns pending promise when action is ask", async () => {
         sessionID: "session_test",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls command",
         metadata: {},
         always: [],
         ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
@@ -491,7 +488,6 @@ test("reply - once resolves the pending ask", async () => {
         sessionID: "session_test",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls command",
         metadata: {},
         always: [],
         ruleset: [],
@@ -517,7 +513,6 @@ test("reply - reject throws RejectedError", async () => {
         sessionID: "session_test",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls command",
         metadata: {},
         always: [],
         ruleset: [],
@@ -543,7 +538,6 @@ test("reply - always persists approval and resolves", async () => {
         sessionID: "session_test",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls command",
         metadata: {},
         always: ["ls"],
         ruleset: [],
@@ -566,7 +560,6 @@ test("reply - always persists approval and resolves", async () => {
         sessionID: "session_test2",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls command",
         metadata: {},
         always: [],
         ruleset: [],
@@ -586,7 +579,6 @@ test("reply - reject cancels all pending for same session", async () => {
         sessionID: "session_same",
         permission: "bash",
         patterns: ["ls"],
-        message: "Run ls",
         metadata: {},
         always: [],
         ruleset: [],
@@ -597,7 +589,6 @@ test("reply - reject cancels all pending for same session", async () => {
         sessionID: "session_same",
         permission: "edit",
         patterns: ["foo.ts"],
-        message: "Edit file",
         metadata: {},
         always: [],
         ruleset: [],
@@ -630,7 +621,6 @@ test("ask - checks all patterns and stops on first deny", async () => {
           sessionID: "session_test",
           permission: "bash",
           patterns: ["echo hello", "rm -rf /"],
-          message: "Run commands",
           metadata: {},
           always: [],
           ruleset: [
@@ -652,7 +642,6 @@ test("ask - allows all patterns when all match allow rules", async () => {
         sessionID: "session_test",
         permission: "bash",
         patterns: ["echo hello", "ls -la", "pwd"],
-        message: "Run safe commands",
         metadata: {},
         always: [],
         ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],

+ 1 - 0
packages/opencode/test/tool/bash.test.ts

@@ -12,6 +12,7 @@ const ctx = {
   agent: "build",
   abort: AbortSignal.any([]),
   metadata: () => {},
+  ask: async () => {},
 }
 
 const projectRoot = path.join(__dirname, "../..")

+ 1 - 0
packages/opencode/test/tool/grep.test.ts

@@ -11,6 +11,7 @@ const ctx = {
   agent: "build",
   abort: AbortSignal.any([]),
   metadata: () => {},
+  ask: async () => {},
 }
 
 const projectRoot = path.join(__dirname, "../..")

+ 2 - 1
packages/opencode/test/tool/patch.test.ts

@@ -9,10 +9,11 @@ import * as fs from "fs/promises"
 const ctx = {
   sessionID: "test",
   messageID: "",
-  toolCallID: "",
+  callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
   metadata: () => {},
+  ask: async () => {},
 }
 
 const patchTool = await PatchTool.init()

+ 1 - 0
packages/opencode/test/tool/read.test.ts

@@ -11,6 +11,7 @@ const ctx = {
   agent: "build",
   abort: AbortSignal.any([]),
   metadata: () => {},
+  ask: async () => {},
 }
 
 describe("tool.read external_directory permission", () => {

+ 4 - 2
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -453,15 +453,17 @@ export type EventMessagePartRemoved = {
 
 export type PermissionRequest = {
   id: string
-  callID?: string
   sessionID: string
   permission: string
   patterns: Array<string>
-  message: string
   metadata: {
     [key: string]: unknown
   }
   always: Array<string>
+  tool?: {
+    messageID: string
+    callID: string
+  }
 }
 
 export type EventPermissionNextAsked = {