Преглед изворни кода

feat: add command-aware permission request system for granular tool approval

Dax Raad пре 3 месеци
родитељ
комит
f24683e661
44 измењених фајлова са 3176 додато и 1384 уклоњено
  1. 0 10
      .opencode/agent/git-committer.md
  2. 1 0
      .opencode/opencode.jsonc
  3. 1 1
      packages/opencode/src/acp/agent.ts
  4. 65 236
      packages/opencode/src/agent/agent.ts
  5. 2 1
      packages/opencode/src/cli/cmd/agent.ts
  6. 3 3
      packages/opencode/src/cli/cmd/run.ts
  7. 2 1
      packages/opencode/src/cli/cmd/tui/app.tsx
  8. 53 0
      packages/opencode/src/cli/cmd/tui/component/dialog-permission.tsx
  9. 22 20
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  10. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
  11. 424 456
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  12. 237 0
      packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
  13. 1 0
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  14. 88 23
      packages/opencode/src/config/config.ts
  15. 162 0
      packages/opencode/src/permission/arity.ts
  16. 4 3
      packages/opencode/src/permission/index.ts
  17. 226 0
      packages/opencode/src/permission/next.ts
  18. 36 0
      packages/opencode/src/server/server.ts
  19. 6 8
      packages/opencode/src/session/llm.ts
  20. 23 28
      packages/opencode/src/session/processor.ts
  21. 3 11
      packages/opencode/src/session/prompt.ts
  22. 1 1
      packages/opencode/src/session/system.ts
  23. 33 68
      packages/opencode/src/tool/bash.ts
  24. 17 15
      packages/opencode/src/tool/codesearch.ts
  25. 42 52
      packages/opencode/src/tool/edit.ts
  26. 19 1
      packages/opencode/src/tool/glob.ts
  27. 20 1
      packages/opencode/src/tool/grep.ts
  28. 18 1
      packages/opencode/src/tool/ls.ts
  29. 28 38
      packages/opencode/src/tool/patch.ts
  30. 27 26
      packages/opencode/src/tool/read.ts
  31. 0 23
      packages/opencode/src/tool/registry.ts
  32. 62 82
      packages/opencode/src/tool/skill.ts
  33. 16 0
      packages/opencode/src/tool/task.ts
  34. 18 16
      packages/opencode/src/tool/webfetch.ts
  35. 20 18
      packages/opencode/src/tool/websearch.ts
  36. 14 14
      packages/opencode/src/tool/write.ts
  37. 385 83
      packages/opencode/test/agent/agent.test.ts
  38. 189 29
      packages/opencode/test/config/config.test.ts
  39. 11 0
      packages/opencode/test/fixture/fixture.ts
  40. 33 0
      packages/opencode/test/permission/arity.test.ts
  41. 663 0
      packages/opencode/test/permission/next.test.ts
  42. 39 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  43. 157 114
      packages/sdk/js/src/v2/gen/types.gen.ts
  44. 4 0
      packages/sdk/openapi.json

+ 0 - 10
.opencode/agent/git-committer.md

@@ -1,10 +0,0 @@
----
-description: Use this agent when you are asked to commit and push code changes to a git repository.
-mode: subagent
----
-
-You commit and push to git
-
-Commit messages should be brief since they are used to generate release notes.
-
-Messages should say WHY the change was made and not WHAT was changed.

+ 1 - 0
.opencode/opencode.jsonc

@@ -5,6 +5,7 @@
   //   "url": "https://enterprise.dev.opencode.ai",
   // },
   "instructions": ["STYLE_GUIDE.md"],
+  "permission": "ask",
   "provider": {
     "opencode": {
       "options": {},

+ 1 - 1
packages/opencode/src/acp/agent.ts

@@ -80,7 +80,7 @@ export namespace ACP {
                     toolCall: {
                       toolCallId: permission.callID ?? permission.id,
                       status: "pending",
-                      title: permission.title,
+                      title: permission.message,
                       rawInput: permission.metadata,
                       kind: toToolKind(permission.type),
                       locations: toLocations(permission.type, permission.metadata),

+ 65 - 236
packages/opencode/src/agent/agent.ts

@@ -4,16 +4,14 @@ import { Provider } from "../provider/provider"
 import { generateObject, type ModelMessage } from "ai"
 import { SystemPrompt } from "../session/system"
 import { Instance } from "../project/instance"
-import { mergeDeep } from "remeda"
-import { Log } from "../util/log"
-
-const log = Log.create({ service: "agent" })
 
 import PROMPT_GENERATE from "./generate.txt"
 import PROMPT_COMPACTION from "./prompt/compaction.txt"
 import PROMPT_EXPLORE from "./prompt/explore.txt"
 import PROMPT_SUMMARY from "./prompt/summary.txt"
 import PROMPT_TITLE from "./prompt/title.txt"
+import { PermissionNext } from "@/permission/next"
+import { mergeDeep } from "remeda"
 
 export namespace Agent {
   export const Info = z
@@ -23,18 +21,10 @@ export namespace Agent {
       mode: z.enum(["subagent", "primary", "all"]),
       native: z.boolean().optional(),
       hidden: z.boolean().optional(),
-      default: z.boolean().optional(),
       topP: z.number().optional(),
       temperature: z.number().optional(),
       color: z.string().optional(),
-      permission: z.object({
-        edit: Config.Permission,
-        bash: z.record(z.string(), Config.Permission),
-        skill: z.record(z.string(), Config.Permission),
-        webfetch: Config.Permission.optional(),
-        doom_loop: Config.Permission.optional(),
-        external_directory: Config.Permission.optional(),
-      }),
+      permission: PermissionNext.Ruleset,
       model: z
         .object({
           modelID: z.string(),
@@ -42,9 +32,8 @@ export namespace Agent {
         })
         .optional(),
       prompt: z.string().optional(),
-      tools: z.record(z.string(), z.boolean()),
       options: z.record(z.string(), z.any()),
-      maxSteps: z.number().int().positive().optional(),
+      steps: z.number().int().positive().optional(),
     })
     .meta({
       ref: "Agent",
@@ -53,113 +42,72 @@ export namespace Agent {
 
   const state = Instance.state(async () => {
     const cfg = await Config.get()
-    const defaultTools = cfg.tools ?? {}
-    const defaultPermission: Info["permission"] = {
-      edit: "allow",
-      bash: {
+    const permission: PermissionNext.Ruleset = PermissionNext.merge(
+      PermissionNext.fromConfig({
         "*": "allow",
-      },
-      skill: {
-        "*": "allow",
-      },
-      webfetch: "allow",
-      doom_loop: "ask",
-      external_directory: "ask",
-    }
-    const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
-
-    const planPermission = mergeAgentPermissions(
-      {
-        edit: "deny",
-        bash: {
-          "cut*": "allow",
-          "diff*": "allow",
-          "du*": "allow",
-          "file *": "allow",
-          "find * -delete*": "ask",
-          "find * -exec*": "ask",
-          "find * -fprint*": "ask",
-          "find * -fls*": "ask",
-          "find * -fprintf*": "ask",
-          "find * -ok*": "ask",
-          "find *": "allow",
-          "git diff*": "allow",
-          "git log*": "allow",
-          "git show*": "allow",
-          "git status*": "allow",
-          "git branch": "allow",
-          "git branch -v": "allow",
-          "grep*": "allow",
-          "head*": "allow",
-          "less*": "allow",
-          "ls*": "allow",
-          "more*": "allow",
-          "pwd*": "allow",
-          "rg*": "allow",
-          "sort --output=*": "ask",
-          "sort -o *": "ask",
-          "sort*": "allow",
-          "stat*": "allow",
-          "tail*": "allow",
-          "tree -o *": "ask",
-          "tree*": "allow",
-          "uniq*": "allow",
-          "wc*": "allow",
-          "whereis*": "allow",
-          "which*": "allow",
-          "*": "ask",
-        },
-        webfetch: "allow",
-      },
-      cfg.permission ?? {},
+        doom_loop: "ask",
+        external_directory: "ask",
+      }),
+      PermissionNext.fromConfig(cfg.permission ?? {}),
     )
 
     const result: Record<string, Info> = {
       build: {
         name: "build",
-        tools: { ...defaultTools },
         options: {},
-        permission: agentPermission,
+        permission,
         mode: "primary",
         native: true,
       },
       plan: {
         name: "plan",
         options: {},
-        permission: planPermission,
-        tools: {
-          ...defaultTools,
-        },
+        permission: PermissionNext.merge(
+          permission,
+          PermissionNext.fromConfig({
+            edit: {
+              "*": "deny",
+              ".opencode/plan/*.md": "allow",
+            },
+          }),
+        ),
         mode: "primary",
         native: true,
       },
       general: {
         name: "general",
         description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
-        tools: {
-          todoread: false,
-          todowrite: false,
-          ...defaultTools,
-        },
+        permission: PermissionNext.merge(
+          permission,
+          PermissionNext.fromConfig({
+            todoread: "deny",
+            todowrite: "deny",
+          }),
+        ),
         options: {},
-        permission: agentPermission,
         mode: "subagent",
         native: true,
         hidden: true,
       },
       explore: {
         name: "explore",
-        tools: {
-          todoread: false,
-          todowrite: false,
-          edit: false,
-          write: false,
-          ...defaultTools,
-        },
+        permission: PermissionNext.merge(
+          permission,
+          PermissionNext.fromConfig({
+            "*": "deny",
+            grep: "allow",
+            glob: "allow",
+            list: "allow",
+            bash: "allow",
+            webfetch: "allow",
+            websearch: "allow",
+            codesearch: "allow",
+            read: "allow",
+          }),
+        ),
         description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
         prompt: PROMPT_EXPLORE,
         options: {},
-        permission: agentPermission,
         mode: "subagent",
         native: true,
       },
@@ -169,11 +117,10 @@ export namespace Agent {
         native: true,
         hidden: true,
         prompt: PROMPT_COMPACTION,
-        tools: {
-          "*": false,
-        },
+        permission: PermissionNext.fromConfig({
+          "*": "deny",
+        }),
         options: {},
-        permission: agentPermission,
       },
       title: {
         name: "title",
@@ -181,9 +128,10 @@ export namespace Agent {
         options: {},
         native: true,
         hidden: true,
-        permission: agentPermission,
+        permission: PermissionNext.fromConfig({
+          "*": "deny",
+        }),
         prompt: PROMPT_TITLE,
-        tools: {},
       },
       summary: {
         name: "summary",
@@ -191,11 +139,13 @@ export namespace Agent {
         options: {},
         native: true,
         hidden: true,
-        permission: agentPermission,
+        permission: PermissionNext.fromConfig({
+          "*": "deny",
+        }),
         prompt: PROMPT_SUMMARY,
-        tools: {},
       },
     }
+
     for (const [key, value] of Object.entries(cfg.agent ?? {})) {
       if (value.disable) {
         delete result[key]
@@ -206,74 +156,22 @@ export namespace Agent {
         item = result[key] = {
           name: key,
           mode: "all",
-          permission: agentPermission,
+          permission,
           options: {},
-          tools: {},
           native: false,
         }
-      const {
-        name,
-        model,
-        prompt,
-        tools,
-        description,
-        temperature,
-        top_p,
-        mode,
-        permission,
-        color,
-        maxSteps,
-        ...extra
-      } = value
-      item.options = {
-        ...item.options,
-        ...extra,
-      }
-      if (model) item.model = Provider.parseModel(model)
-      if (prompt) item.prompt = prompt
-      if (tools)
-        item.tools = {
-          ...item.tools,
-          ...tools,
-        }
-      item.tools = {
-        ...defaultTools,
-        ...item.tools,
-      }
-      if (description) item.description = description
-      if (temperature != undefined) item.temperature = temperature
-      if (top_p != undefined) item.topP = top_p
-      if (mode) item.mode = mode
-      if (color) item.color = color
-      // just here for consistency & to prevent it from being added as an option
-      if (name) item.name = name
-      if (maxSteps != undefined) item.maxSteps = maxSteps
-
-      if (permission ?? cfg.permission) {
-        item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
-      }
+      if (value.model) item.model = Provider.parseModel(value.model)
+      item.prompt = value.prompt ?? item.prompt
+      item.description = value.description ?? item.description
+      item.temperature = value.temperature ?? item.temperature
+      item.topP = value.top_p ?? item.topP
+      item.mode = value.mode ?? item.mode
+      item.color = value.color ?? item.color
+      item.name = value.options?.name ?? item.name
+      item.steps = value.steps ?? item.steps
+      item.options = mergeDeep(item.options, value.options ?? {})
+      item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
     }
-
-    // Mark the default agent
-    const defaultName = cfg.default_agent ?? "build"
-    const defaultCandidate = result[defaultName]
-    if (defaultCandidate && defaultCandidate.mode !== "subagent") {
-      defaultCandidate.default = true
-    } else {
-      // Fall back to "build" if configured default is invalid
-      if (result["build"]) {
-        result["build"].default = true
-      }
-    }
-
-    const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
-    if (!hasPrimaryAgents) {
-      throw new Config.InvalidError({
-        path: "config",
-        message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
-      })
-    }
-
     return result
   })
 
@@ -285,10 +183,8 @@ export namespace Agent {
     return state().then((x) => Object.values(x))
   }
 
-  export async function defaultAgent(): Promise<string> {
-    const agents = await state()
-    const defaultCandidate = Object.values(agents).find((a) => a.default)
-    return defaultCandidate?.name ?? "build"
+  export async function defaultAgent() {
+    return state().then((x) => Object.keys(x)[0])
   }
 
   export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
@@ -329,70 +225,3 @@ export namespace Agent {
     return result.object
   }
 }
-
-function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
-  if (typeof basePermission.bash === "string") {
-    basePermission.bash = {
-      "*": basePermission.bash,
-    }
-  }
-  if (typeof overridePermission.bash === "string") {
-    overridePermission.bash = {
-      "*": overridePermission.bash,
-    }
-  }
-
-  if (typeof basePermission.skill === "string") {
-    basePermission.skill = {
-      "*": basePermission.skill,
-    }
-  }
-  if (typeof overridePermission.skill === "string") {
-    overridePermission.skill = {
-      "*": overridePermission.skill,
-    }
-  }
-  const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
-  let mergedBash
-  if (merged.bash) {
-    if (typeof merged.bash === "string") {
-      mergedBash = {
-        "*": merged.bash,
-      }
-    } else if (typeof merged.bash === "object") {
-      mergedBash = mergeDeep(
-        {
-          "*": "allow",
-        },
-        merged.bash,
-      )
-    }
-  }
-
-  let mergedSkill
-  if (merged.skill) {
-    if (typeof merged.skill === "string") {
-      mergedSkill = {
-        "*": merged.skill,
-      }
-    } else if (typeof merged.skill === "object") {
-      mergedSkill = mergeDeep(
-        {
-          "*": "allow",
-        },
-        merged.skill,
-      )
-    }
-  }
-
-  const result: Agent.Info["permission"] = {
-    edit: merged.edit ?? "allow",
-    webfetch: merged.webfetch ?? "allow",
-    bash: mergedBash ?? { "*": "allow" },
-    skill: mergedSkill ?? { "*": "allow" },
-    doom_loop: merged.doom_loop,
-    external_directory: merged.external_directory,
-  }
-
-  return result
-}

+ 2 - 1
packages/opencode/src/cli/cmd/agent.ts

@@ -241,7 +241,8 @@ const AgentListCommand = cmd({
         })
 
         for (const agent of sortedAgents) {
-          process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
+          process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
+          process.stdout.write(`  ${JSON.stringify(agent.permission, null, 2)}` + EOL)
         }
       },
     })

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

@@ -202,14 +202,14 @@ export const RunCommand = cmd({
             break
           }
 
-          if (event.type === "permission.updated") {
+          if (event.type === "permission.next.asked") {
             const permission = event.properties
             if (permission.sessionID !== sessionID) continue
             const result = await select({
-              message: `Permission required to run: ${permission.title}`,
+              message: `Permission required to run: ${permission.message}`,
               options: [
                 { value: "once", label: "Allow once" },
-                { value: "always", label: "Always allow" },
+                { value: "always", label: "Always allow: " + permission.always.join(", ") },
                 { value: "reject", label: "Reject" },
               ],
               initialValue: "once",

+ 2 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
 import { RouteProvider, useRoute } from "@tui/context/route"
 import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
 import { Installation } from "@/installation"
-import { Global } from "@/global"
 import { Flag } from "@/flag/flag"
 import { DialogProvider, useDialog } from "@tui/ui/dialog"
 import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
@@ -35,6 +34,7 @@ import { Provider } from "@/provider/provider"
 import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
+import { Permission } from "./component/dialog-permission"
 
 async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
   // can't set raw mode if not a TTY
@@ -608,6 +608,7 @@ function App() {
         }
       }}
     >
+      <Permission />
       <Switch>
         <Match when={route.data.type === "home"}>
           <Home />

+ 53 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-permission.tsx

@@ -0,0 +1,53 @@
+import { onMount } from "solid-js"
+import { useDialog } from "../ui/dialog"
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+
+export function Permission() {
+  const dialog = useDialog()
+  onMount(() => {})
+  return null
+}
+
+function DialogPermission() {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+
+  onMount(() => {
+    dialog.setSize("medium")
+  })
+
+  return (
+    <box
+      gap={1}
+      paddingLeft={2}
+      paddingRight={2}
+      onKeyDown={(e) => {
+        console.log(e)
+      }}
+      ref={(r) => {
+        setTimeout(() => {
+          r?.focus()
+        }, 1)
+      }}
+    >
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD}>Permission Request</text>
+        <text fg={theme.textMuted}>esc</text>
+      </box>
+      <text fg={theme.textMuted}>Change to foo directory and create bar file</text>
+      <text>$ cd foo && touch bar</text>
+      <box paddingBottom={1}>
+        <box paddingLeft={2} paddingRight={2} backgroundColor={theme.primary}>
+          <text fg={theme.background}>Allow</text>
+        </box>
+        <box paddingLeft={2} paddingRight={2}>
+          <text>Always allow the touch command</text>
+        </box>
+        <box paddingLeft={2} paddingRight={2}>
+          <text>Reject</text>
+        </box>
+      </box>
+    </box>
+  )
+}

+ 22 - 20
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -7,7 +7,7 @@ import type {
   Config,
   Todo,
   Command,
-  Permission,
+  PermissionRequest,
   LspStatus,
   McpStatus,
   FormatterStatus,
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       agent: Agent[]
       command: Command[]
       permission: {
-        [sessionID: string]: Permission[]
+        [sessionID: string]: PermissionRequest[]
       }
       config: Config
       session: Session[]
@@ -97,36 +97,38 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     sdk.event.listen((e) => {
       const event = e.details
       switch (event.type) {
-        case "permission.updated": {
-          const permissions = store.permission[event.properties.sessionID]
-          if (!permissions) {
-            setStore("permission", event.properties.sessionID, [event.properties])
-            break
-          }
-          const match = Binary.search(permissions, event.properties.id, (p) => p.id)
+        case "permission.next.replied": {
+          const requests = store.permission[event.properties.sessionID]
+          if (!requests) break
+          const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
+          if (!match.found) break
           setStore(
             "permission",
             event.properties.sessionID,
             produce((draft) => {
-              if (match.found) {
-                draft[match.index] = event.properties
-                return
-              }
-              draft.push(event.properties)
+              draft.splice(match.index, 1)
             }),
           )
           break
         }
 
-        case "permission.replied": {
-          const permissions = store.permission[event.properties.sessionID]
-          const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
-          if (!match.found) break
+        case "permission.next.asked": {
+          const request = event.properties
+          const requests = store.permission[request.sessionID]
+          if (!requests) {
+            setStore("permission", request.sessionID, [request])
+            break
+          }
+          const match = Binary.search(requests, request.id, (r) => r.id)
+          if (match.found) {
+            setStore("permission", request.sessionID, match.index, reconcile(request))
+            break
+          }
           setStore(
             "permission",
-            event.properties.sessionID,
+            request.sessionID,
             produce((draft) => {
-              draft.splice(match.index, 1)
+              draft.splice(match.index, 0, request)
             }),
           )
           break

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx

@@ -59,7 +59,7 @@ export function Footer() {
           <Match when={connected()}>
             <Show when={permissions().length > 0}>
               <text fg={theme.warning}>
-                <span style={{ fg: theme.warning }}></span> {permissions().length} Permission
+                <span style={{ fg: theme.warning }}></span> {permissions().length} Permission
                 {permissions().length > 1 ? "s" : ""}
               </text>
             </Show>

+ 424 - 456
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -9,7 +9,6 @@ import {
   Show,
   Switch,
   useContext,
-  type Component,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import path from "path"
@@ -23,6 +22,7 @@ import {
   addDefaultParsers,
   MacOSScrollAccel,
   type ScrollAcceleration,
+  TextAttributes,
 } from "@opentui/core"
 import { Prompt, type PromptRef } from "@tui/component/prompt"
 import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
@@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit"
 import type { PatchTool } from "@/tool/patch"
 import type { WebFetchTool } from "@/tool/webfetch"
 import type { TaskTool } from "@/tool/task"
-import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
+import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "@tui/context/sdk"
 import { useCommandDialog } from "@tui/component/dialog-command"
 import { useKeybind } from "@tui/context/keybind"
@@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi"
 import { Footer } from "./footer.tsx"
 import { usePromptRef } from "../../context/prompt"
 import { Filesystem } from "@/util/filesystem"
+import { PermissionPrompt } from "./permission"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 
 addDefaultParsers(parsers.parsers)
@@ -82,12 +83,12 @@ class CustomSpeedScroll implements ScrollAcceleration {
 
 const context = createContext<{
   width: number
+  sessionID: string
   conceal: () => boolean
   showThinking: () => boolean
   showTimestamps: () => boolean
   usernameVisible: () => boolean
   showDetails: () => boolean
-  userMessageMarkdown: () => boolean
   diffWrapMode: () => "word" | "none"
   sync: ReturnType<typeof useSync>
 }>()
@@ -125,7 +126,6 @@ export function Session() {
   const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
   const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
   const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
-  const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
   const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
   const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
 
@@ -571,19 +571,6 @@ export function Session() {
         dialog.clear()
       },
     },
-    {
-      title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown",
-      value: "session.toggle.user_message_markdown",
-      category: "Session",
-      onSelect: (dialog) => {
-        setUserMessageMarkdown((prev) => {
-          const next = !prev
-          kv.set("user_message_markdown", next)
-          return next
-        })
-        dialog.clear()
-      },
-    },
     {
       title: animationsEnabled() ? "Disable animations" : "Enable animations",
       value: "session.toggle.animations",
@@ -990,12 +977,12 @@ export function Session() {
         get width() {
           return contentWidth()
         },
+        sessionID: route.sessionID,
         conceal,
         showThinking,
         showTimestamps,
         usernameVisible,
         showDetails,
-        userMessageMarkdown,
         diffWrapMode,
         sync,
       }}
@@ -1121,17 +1108,24 @@ export function Session() {
               </For>
             </scrollbox>
             <box flexShrink={0}>
-              <Prompt
-                ref={(r) => {
-                  prompt = r
-                  promptRef.set(r)
-                }}
-                disabled={permissions().length > 0}
-                onSubmit={() => {
-                  toBottom()
-                }}
-                sessionID={route.sessionID}
-              />
+              <Switch>
+                <Match when={permissions().length > 0}>
+                  <PermissionPrompt request={permissions()[0]} />
+                </Match>
+                <Match when={true}>
+                  <Prompt
+                    ref={(r) => {
+                      prompt = r
+                      promptRef.set(r)
+                    }}
+                    disabled={permissions().length > 0}
+                    onSubmit={() => {
+                      toBottom()
+                    }}
+                    sessionID={route.sessionID}
+                  />
+                </Match>
+              </Switch>
             </box>
             <Show when={!sidebarVisible()}>
               <Footer />
@@ -1169,7 +1163,7 @@ function UserMessage(props: {
   const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
   const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
   const sync = useSync()
-  const { theme, syntax } = useTheme()
+  const { theme } = useTheme()
   const [hover, setHover] = createSignal(false)
   const queued = createMemo(() => props.pending && props.message.id > props.pending)
   const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
@@ -1200,22 +1194,7 @@ function UserMessage(props: {
             backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
             flexShrink={0}
           >
-            <Switch>
-              <Match when={ctx.userMessageMarkdown()}>
-                <code
-                  filetype="markdown"
-                  drawUnstyledText={false}
-                  streaming={false}
-                  syntaxStyle={syntax()}
-                  content={text()?.text ?? ""}
-                  conceal={ctx.conceal()}
-                  fg={theme.text}
-                />
-              </Match>
-              <Match when={!ctx.userMessageMarkdown()}>
-                <text fg={theme.text}>{text()?.text}</text>
-              </Match>
-            </Switch>
+            <text fg={theme.text}>{text()?.text}</text>
             <Show when={files().length}>
               <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
                 <For each={files()}>
@@ -1321,7 +1300,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
         <Match when={props.last || final()}>
           <box paddingLeft={3}>
             <text marginTop={1}>
-              <span style={{ fg: local.agent.color(props.message.mode) }}>▣ </span>{" "}
+              <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>{" "}
               <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
               <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
               <Show when={duration()}>
@@ -1397,112 +1376,77 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
 // Pending messages moved to individual tool pending functions
 
 function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
-  const { theme } = useTheme()
-  const { showDetails } = use()
   const sync = useSync()
-  const [margin, setMargin] = createSignal(0)
-  const component = createMemo(() => {
-    // Hide tool if showDetails is false and tool completed successfully
-    // But always show if there's an error or permission is required
-    const shouldHide =
-      !showDetails() &&
-      props.part.state.status === "completed" &&
-      !sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID)
-
-    if (shouldHide) {
-      return undefined
-    }
 
-    const render = ToolRegistry.render(props.part.tool) ?? GenericTool
-
-    const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
-    const input = props.part.state.input ?? {}
-    const container = ToolRegistry.container(props.part.tool)
-    const permissions = sync.data.permission[props.message.sessionID] ?? []
-    const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
-    const permission = permissions[permissionIndex]
-
-    const style: BoxProps =
-      container === "block" || permission
-        ? {
-            border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
-            paddingTop: 1,
-            paddingBottom: 1,
-            paddingLeft: 2,
-            marginTop: 1,
-            gap: 1,
-            backgroundColor: theme.backgroundPanel,
-            customBorderChars: SplitBorder.customBorderChars,
-            borderColor: permissionIndex === 0 ? theme.warning : theme.background,
-          }
-        : {
-            paddingLeft: 3,
-          }
-
-    return (
-      <box
-        marginTop={margin()}
-        {...style}
-        renderBefore={function () {
-          const el = this as BoxRenderable
-          const parent = el.parent
-          if (!parent) {
-            return
-          }
-          if (el.height > 1) {
-            setMargin(1)
-            return
-          }
-          const children = parent.getChildren()
-          const index = children.indexOf(el)
-          const previous = children[index - 1]
-          if (!previous) {
-            setMargin(0)
-            return
-          }
-          if (previous.height > 1 || previous.id.startsWith("text-")) {
-            setMargin(1)
-            return
-          }
-        }}
-      >
-        <Dynamic
-          component={render}
-          input={input}
-          tool={props.part.tool}
-          metadata={metadata}
-          permission={permission?.metadata ?? {}}
-          output={props.part.state.status === "completed" ? props.part.state.output : undefined}
-        />
-        {props.part.state.status === "error" && (
-          <box paddingLeft={2}>
-            <text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
-          </box>
-        )}
-        {permission && (
-          <box gap={1}>
-            <text fg={theme.text}>Permission required to run this tool:</text>
-            <box flexDirection="row" gap={2}>
-              <text fg={theme.text}>
-                <b>enter</b>
-                <span style={{ fg: theme.textMuted }}> accept</span>
-              </text>
-              <text fg={theme.text}>
-                <b>a</b>
-                <span style={{ fg: theme.textMuted }}> accept always</span>
-              </text>
-              <text fg={theme.text}>
-                <b>d</b>
-                <span style={{ fg: theme.textMuted }}> deny</span>
-              </text>
-            </box>
-          </box>
-        )}
-      </box>
-    )
-  })
+  const toolprops = {
+    get metadata() {
+      return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
+    },
+    get input() {
+      return props.part.state.input ?? {}
+    },
+    get output() {
+      return props.part.state.status === "completed" ? props.part.state.output : undefined
+    },
+    get permission() {
+      const permissions = sync.data.permission[props.message.sessionID] ?? []
+      const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
+      return permissions[permissionIndex]
+    },
+    get tool() {
+      return props.part.tool
+    },
+    get part() {
+      return props.part
+    },
+  }
 
-  return <Show when={component()}>{component()}</Show>
+  return (
+    <Switch>
+      <Match when={props.part.tool === "bash"}>
+        <Bash {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "glob"}>
+        <Glob {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "read"}>
+        <Read {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "grep"}>
+        <Grep {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "list"}>
+        <List {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "webfetch"}>
+        <WebFetch {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "codesearch"}>
+        <CodeSearch {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "websearch"}>
+        <WebSearch {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "write"}>
+        <Write {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "edit"}>
+        <Edit {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "task"}>
+        <Task {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "patch"}>
+        <Patch {...toolprops} />
+      </Match>
+      <Match when={props.part.tool === "todowrite"}>
+        <TodoWrite {...toolprops} />
+      </Match>
+      <Match when={true}>
+        <GenericTool {...toolprops} />
+      </Match>
+    </Switch>
+  )
 }
 
 type ToolProps<T extends Tool.Info> = {
@@ -1511,37 +1455,16 @@ type ToolProps<T extends Tool.Info> = {
   permission: Record<string, any>
   tool: string
   output?: string
+  part: ToolPart
 }
 function GenericTool(props: ToolProps<any>) {
   return (
-    <ToolTitle icon="⚙" fallback="Writing command..." when={true}>
+    <InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
       {props.tool} {input(props.input)}
-    </ToolTitle>
+    </InlineTool>
   )
 }
 
-type ToolRegistration<T extends Tool.Info = any> = {
-  name: string
-  container: "inline" | "block"
-  render?: Component<ToolProps<T>>
-}
-const ToolRegistry = (() => {
-  const state: Record<string, ToolRegistration> = {}
-  function register<T extends Tool.Info>(input: ToolRegistration<T>) {
-    state[input.name] = input
-    return input
-  }
-  return {
-    register,
-    container(name: string) {
-      return state[name]?.container
-    },
-    render(name: string) {
-      return state[name]?.render
-    },
-  }
-})()
-
 function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
   const { theme } = useTheme()
   return (
@@ -1553,67 +1476,129 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
   )
 }
 
-ToolRegistry.register<typeof BashTool>({
-  name: "bash",
-  container: "block",
-  render(props) {
-    const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
-    const { theme } = useTheme()
-    return (
-      <>
-        <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
-          {props.input.description || "Shell"}
-        </ToolTitle>
-        <Show when={props.input.command}>
-          <text fg={theme.text}>$ {props.input.command}</text>
+function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) {
+  const [margin, setMargin] = createSignal(0)
+  const { theme } = useTheme()
+  const ctx = use()
+  const sync = useSync()
+
+  const permission = createMemo(() => {
+    const callID = sync.data.permission[ctx.sessionID]?.at(0)?.callID
+    if (!callID) return false
+    return callID === props.part.callID
+  })
+
+  const fg = createMemo(() => {
+    if (props.complete) return theme.textMuted
+    return theme.text
+  })
+
+  const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
+
+  const denied = createMemo(() => error()?.includes("rejected permission"))
+
+  return (
+    <box
+      marginTop={margin()}
+      paddingLeft={3}
+      renderBefore={function () {
+        const el = this as BoxRenderable
+        const parent = el.parent
+        if (!parent) {
+          return
+        }
+        if (el.height > 1) {
+          setMargin(1)
+          return
+        }
+        const children = parent.getChildren()
+        const index = children.indexOf(el)
+        const previous = children[index - 1]
+        if (!previous) {
+          setMargin(0)
+          return
+        }
+        if (previous.height > 1 || previous.id.startsWith("text-")) {
+          setMargin(1)
+          return
+        }
+      }}
+    >
+      <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
+        <Show fallback={<>~ {props.pending}</>} when={props.complete}>
+          <span style={{ bold: true }}>{props.icon}</span> {props.children}
         </Show>
-        <Show when={output()}>
-          <box>
+        <Show when={permission()}>
+          ·<span style={{ fg: theme.warning }}> Permission required</span>
+        </Show>
+      </text>
+      <Show when={error() && !denied()}>
+        <text fg={theme.error}>{error()}</text>
+      </Show>
+    </box>
+  )
+}
+
+function BlockTool(props: { title: string; children: JSX.Element }) {
+  const { theme } = useTheme()
+  return (
+    <box
+      border={["left"]}
+      paddingTop={1}
+      paddingBottom={1}
+      paddingLeft={2}
+      marginTop={1}
+      gap={1}
+      backgroundColor={theme.backgroundPanel}
+      customBorderChars={SplitBorder.customBorderChars}
+      borderColor={theme.background}
+    >
+      <text paddingLeft={3} fg={theme.textMuted}>
+        {props.title}
+      </text>
+      {props.children}
+    </box>
+  )
+}
+
+function Bash(props: ToolProps<typeof BashTool>) {
+  const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
+  const { theme } = useTheme()
+  return (
+    <Switch>
+      <Match when={props.metadata.output !== undefined}>
+        <BlockTool title={"# " + (props.input.description ?? "Shell")}>
+          <box gap={1}>
+            <text fg={theme.text}>$ {props.input.command}</text>
             <text fg={theme.text}>{output()}</text>
           </box>
-        </Show>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof ReadTool>({
-  name: "read",
-  container: "inline",
-  render(props) {
-    return (
-      <>
-        <ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
-          Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
-        </ToolTitle>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof WriteTool>({
-  name: "write",
-  container: "block",
-  render(props) {
-    const { theme, syntax } = useTheme()
-    const code = createMemo(() => {
-      if (!props.input.content) return ""
-      return props.input.content
-    })
-
-    const diagnostics = createMemo(() => {
-      const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
-      return props.metadata.diagnostics?.[filePath] ?? []
-    })
-
-    const done = !!props.input.filePath
-
-    return (
-      <>
-        <ToolTitle icon="←" fallback="Preparing write..." when={done}>
-          Wrote {props.input.filePath}
-        </ToolTitle>
-        <Show when={done}>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
+          {props.input.command}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Write(props: ToolProps<typeof WriteTool>) {
+  const { theme, syntax } = useTheme()
+  const code = createMemo(() => {
+    if (!props.input.content) return ""
+    return props.input.content
+  })
+
+  const diagnostics = createMemo(() => {
+    const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+    return props.metadata.diagnostics?.[filePath] ?? []
+  })
+
+  return (
+    <Switch>
+      <Match when={props.metadata.diagnostics !== undefined}>
+        <BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)}>
           <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
             <code
               conceal={false}
@@ -1623,180 +1608,160 @@ ToolRegistry.register<typeof WriteTool>({
               content={code()}
             />
           </line_number>
-        </Show>
-        <Show when={diagnostics().length}>
-          <For each={diagnostics()}>
-            {(diagnostic) => (
-              <text fg={theme.error}>
-                Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
-              </text>
-            )}
-          </For>
-        </Show>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof GlobTool>({
-  name: "glob",
-  container: "inline",
-  render(props) {
-    return (
-      <>
-        <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
-          Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
-          <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
-        </ToolTitle>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof GrepTool>({
-  name: "grep",
-  container: "inline",
-  render(props) {
-    return (
-      <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
-        Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
-        <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
-      </ToolTitle>
-    )
-  },
-})
-
-ToolRegistry.register<typeof ListTool>({
-  name: "list",
-  container: "inline",
-  render(props) {
-    const dir = createMemo(() => {
-      if (props.input.path) {
-        return normalizePath(props.input.path)
-      }
-      return ""
-    })
-    return (
-      <>
-        <ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
-          List {dir()}
-        </ToolTitle>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof TaskTool>({
-  name: "task",
-  container: "block",
-  render(props) {
-    const { theme } = useTheme()
-    const keybind = useKeybind()
-    const dialog = useDialog()
-    const renderer = useRenderer()
-
-    return (
-      <>
-        <ToolTitle icon="◉" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
-          {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
-        </ToolTitle>
-        <Show when={props.metadata.summary?.length}>
-          <box>
-            <For each={props.metadata.summary ?? []}>
-              {(task, index) => {
-                const summary = props.metadata.summary ?? []
-                return (
-                  <text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
-                    {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
-                    {task.state.status === "completed" ? task.state.title : ""}
-                  </text>
-                )
-              }}
+          <Show when={diagnostics().length}>
+            <For each={diagnostics()}>
+              {(diagnostic) => (
+                <text fg={theme.error}>
+                  Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
+                </text>
+              )}
             </For>
+          </Show>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
+          Write {normalizePath(props.input.filePath!)}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Glob(props: ToolProps<typeof GlobTool>) {
+  return (
+    <InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
+      Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
+      <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
+    </InlineTool>
+  )
+}
+
+function Read(props: ToolProps<typeof ReadTool>) {
+  return (
+    <InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
+      Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
+    </InlineTool>
+  )
+}
+
+function Grep(props: ToolProps<typeof GrepTool>) {
+  return (
+    <InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
+      Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
+      <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
+    </InlineTool>
+  )
+}
+
+function List(props: ToolProps<typeof ListTool>) {
+  const dir = createMemo(() => {
+    if (props.input.path) {
+      return normalizePath(props.input.path)
+    }
+    return ""
+  })
+  return (
+    <InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
+      List {dir()}
+    </InlineTool>
+  )
+}
+
+function WebFetch(props: ToolProps<typeof WebFetchTool>) {
+  return (
+    <InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
+      WebFetch {(props.input as any).url}
+    </InlineTool>
+  )
+}
+
+function CodeSearch(props: ToolProps<any>) {
+  const input = props.input as any
+  const metadata = props.metadata as any
+  return (
+    <InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
+      Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
+    </InlineTool>
+  )
+}
+
+function WebSearch(props: ToolProps<any>) {
+  const input = props.input as any
+  const metadata = props.metadata as any
+  return (
+    <InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
+      Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
+    </InlineTool>
+  )
+}
+
+function Task(props: ToolProps<typeof TaskTool>) {
+  const { theme } = useTheme()
+  const keybind = useKeybind()
+
+  const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
+
+  return (
+    <Switch>
+      <Match when={props.metadata.summary?.length}>
+        <BlockTool title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}>
+          <box>
+            <text style={{ fg: theme.textMuted }}>
+              {props.input.description} ({props.metadata.summary?.length} toolcalls)
+            </text>
+            <Show when={current()}>
+              <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
+                └ {Locale.titlecase(current()!.tool)}{" "}
+                {current()!.state.status === "completed" ? current()!.state.title : ""}
+              </text>
+            </Show>
           </box>
-        </Show>
-        <text fg={theme.text}>
-          {keybind.print("session_child_cycle")}
-          <span style={{ fg: theme.textMuted }}> view subagents</span>
-        </text>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof WebFetchTool>({
-  name: "webfetch",
-  container: "inline",
-  render(props) {
-    return (
-      <ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
-        WebFetch {(props.input as any).url}
-      </ToolTitle>
-    )
-  },
-})
-
-ToolRegistry.register({
-  name: "codesearch",
-  container: "inline",
-  render(props: ToolProps<any>) {
-    const input = props.input as any
-    const metadata = props.metadata as any
-    return (
-      <ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
-        Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
-      </ToolTitle>
-    )
-  },
-})
-
-ToolRegistry.register({
-  name: "websearch",
-  container: "inline",
-  render(props: ToolProps<any>) {
-    const input = props.input as any
-    const metadata = props.metadata as any
-    return (
-      <ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
-        Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
-      </ToolTitle>
-    )
-  },
-})
-
-ToolRegistry.register<typeof EditTool>({
-  name: "edit",
-  container: "block",
-  render(props) {
-    const ctx = use()
-    const { theme, syntax } = useTheme()
-
-    const view = createMemo(() => {
-      const diffStyle = ctx.sync.data.config.tui?.diff_style
-      if (diffStyle === "stacked") return "unified"
-      // Default to "auto" behavior
-      return ctx.width > 120 ? "split" : "unified"
-    })
-
-    const ft = createMemo(() => filetype(props.input.filePath))
-
-    const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
-
-    const diagnostics = createMemo(() => {
-      const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
-      const arr = props.metadata.diagnostics?.[filePath] ?? []
-      return arr.filter((x) => x.severity === 1).slice(0, 3)
-    })
-
-    return (
-      <>
-        <ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
-          Edit {normalizePath(props.input.filePath!)}{" "}
-          {input({
-            replaceAll: props.input.replaceAll,
-          })}
-        </ToolTitle>
-        <Show when={diffContent()}>
+          <text fg={theme.text}>
+            {keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
+            <span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
+          </text>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool
+          icon="◉"
+          pending="Delegating..."
+          complete={props.input.subagent_type ?? props.input.description}
+          part={props.part}
+        >
+          {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Edit(props: ToolProps<typeof EditTool>) {
+  const ctx = use()
+  const { theme, syntax } = useTheme()
+
+  const view = createMemo(() => {
+    const diffStyle = ctx.sync.data.config.tui?.diff_style
+    if (diffStyle === "stacked") return "unified"
+    // Default to "auto" behavior
+    return ctx.width > 120 ? "split" : "unified"
+  })
+
+  const ft = createMemo(() => filetype(props.input.filePath))
+
+  const diffContent = createMemo(() => props.metadata.diff)
+
+  const diagnostics = createMemo(() => {
+    const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+    const arr = props.metadata.diagnostics?.[filePath] ?? []
+    return arr.filter((x) => x.severity === 1).slice(0, 3)
+  })
+
+  return (
+    <Switch>
+      <Match when={props.metadata.diff !== undefined}>
+        <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)}>
           <box paddingLeft={1}>
             <diff
               diff={diffContent()}
@@ -1818,66 +1783,69 @@ ToolRegistry.register<typeof EditTool>({
               removedLineNumberBg={theme.diffRemovedLineNumberBg}
             />
           </box>
-        </Show>
-        <Show when={diagnostics().length}>
-          <box>
-            <For each={diagnostics()}>
-              {(diagnostic) => (
-                <text fg={theme.error}>
-                  Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
-                </text>
-              )}
-            </For>
-          </box>
-        </Show>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof PatchTool>({
-  name: "patch",
-  container: "block",
-  render(props) {
-    const { theme } = useTheme()
-    return (
-      <>
-        <ToolTitle icon="%" fallback="Preparing patch..." when={true}>
-          Patch
-        </ToolTitle>
-        <Show when={props.output}>
+          <Show when={diagnostics().length}>
+            <box>
+              <For each={diagnostics()}>
+                {(diagnostic) => (
+                  <text fg={theme.error}>
+                    Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
+                    {diagnostic.message}
+                  </text>
+                )}
+              </For>
+            </box>
+          </Show>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
+          Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Patch(props: ToolProps<typeof PatchTool>) {
+  const { theme } = useTheme()
+  return (
+    <Switch>
+      <Match when={props.output !== undefined}>
+        <BlockTool title="# Patch">
           <box>
             <text fg={theme.text}>{props.output?.trim()}</text>
           </box>
-        </Show>
-      </>
-    )
-  },
-})
-
-ToolRegistry.register<typeof TodoWriteTool>({
-  name: "todowrite",
-  container: "block",
-  render(props) {
-    const { theme } = useTheme()
-    return (
-      <>
-        <Show when={!props.input.todos?.length}>
-          <ToolTitle icon="⚙" fallback="Updating todos..." when={true}>
-            Updating todos...
-          </ToolTitle>
-        </Show>
-        <Show when={props.metadata.todos?.length}>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
+          Patch
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
+  return (
+    <Switch>
+      <Match when={props.metadata.todos?.length}>
+        <BlockTool title="# Todos">
           <box>
             <For each={props.input.todos ?? []}>
               {(todo) => <TodoItem status={todo.status} content={todo.content} />}
             </For>
           </box>
-        </Show>
-      </>
-    )
-  },
-})
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
+          Updating todos...
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
 
 function normalizePath(input?: string) {
   if (!input) return ""

+ 237 - 0
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -0,0 +1,237 @@
+import { createStore } from "solid-js/store"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
+import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
+import { useTheme } from "../../context/theme"
+import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../../context/sdk"
+import { SplitBorder } from "../../component/border"
+import { useSync } from "../../context/sync"
+import path from "path"
+import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+
+function normalizePath(input?: string) {
+  if (!input) return ""
+  if (path.isAbsolute(input)) {
+    return path.relative(process.cwd(), input) || "."
+  }
+  return input
+}
+
+function filetype(input?: string) {
+  if (!input) return "none"
+  const ext = path.extname(input)
+  const language = LANGUAGE_EXTENSIONS[ext]
+  if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
+  return language
+}
+
+function EditBody(props: { request: PermissionRequest }) {
+  const { theme, syntax } = useTheme()
+  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 view = createMemo(() => {
+    const diffStyle = sync.data.config.tui?.diff_style
+    if (diffStyle === "stacked") return "unified"
+    return dimensions().width > 120 ? "split" : "unified"
+  })
+
+  const ft = createMemo(() => filetype(filepath()))
+
+  return (
+    <box flexDirection="column" gap={1}>
+      <box flexDirection="row" gap={1}>
+        <text fg={theme.textMuted}>{"→"}</text>
+        <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
+      </box>
+      <Show when={diff()}>
+        <box>
+          <diff
+            diff={diff()}
+            view={view()}
+            filetype={ft()}
+            syntaxStyle={syntax()}
+            showLineNumbers={true}
+            width="100%"
+            wrapMode="word"
+            fg={theme.text}
+            addedBg={theme.diffAddedBg}
+            removedBg={theme.diffRemovedBg}
+            contextBg={theme.diffContextBg}
+            addedSignColor={theme.diffHighlightAdded}
+            removedSignColor={theme.diffHighlightRemoved}
+            lineNumberFg={theme.diffLineNumber}
+            lineNumberBg={theme.diffContextBg}
+            addedLineNumberBg={theme.diffAddedLineNumberBg}
+            removedLineNumberBg={theme.diffRemovedLineNumberBg}
+          />
+        </box>
+      </Show>
+    </box>
+  )
+}
+
+function TextBody(props: { text: 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>
+  )
+}
+
+export function PermissionPrompt(props: { request: PermissionRequest }) {
+  const sdk = useSDK()
+  const [store, setStore] = createStore({
+    always: false,
+  })
+
+  const metadata = props.request.metadata as { filepath?: string }
+
+  return (
+    <Switch>
+      <Match when={store.always}>
+        <Prompt
+          title="Always allow"
+          body={<TextBody text={props.request.always.join("\n")} />}
+          options={{ confirm: "Confirm", cancel: "Cancel" }}
+          onSelect={(option) => {
+            if (option === "cancel") {
+              setStore("always", false)
+              return
+            }
+            sdk.client.permission.reply({
+              reply: "always",
+              requestID: props.request.id,
+            })
+          }}
+        />
+      </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} />}
+          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>
+    </Switch>
+  )
+}
+
+function Prompt<const T extends Record<string, string>>(props: {
+  title: string
+  body: JSX.Element
+  options: T
+  onSelect: (option: keyof T) => void
+}) {
+  const { theme } = useTheme()
+  const keys = Object.keys(props.options) as (keyof T)[]
+  const [store, setStore] = createStore({
+    selected: keys[0],
+  })
+
+  useKeyboard((evt) => {
+    if (evt.name === "left" || evt.name == "h") {
+      evt.preventDefault()
+      const idx = keys.indexOf(store.selected)
+      const next = keys[(idx - 1 + keys.length) % keys.length]
+      setStore("selected", next)
+    }
+
+    if (evt.name === "right" || evt.name == "l") {
+      evt.preventDefault()
+      const idx = keys.indexOf(store.selected)
+      const next = keys[(idx + 1) % keys.length]
+      setStore("selected", next)
+    }
+
+    if (evt.name === "return") {
+      evt.preventDefault()
+      props.onSelect(store.selected)
+    }
+  })
+
+  return (
+    <box
+      backgroundColor={theme.backgroundPanel}
+      border={["left"]}
+      borderColor={theme.warning}
+      customBorderChars={SplitBorder.customBorderChars}
+    >
+      <box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
+        <box flexDirection="row" gap={1}>
+          <text fg={theme.warning}>{"△"}</text>
+          <text fg={theme.text}>{props.title}</text>
+        </box>
+        {props.body}
+      </box>
+      <box
+        flexDirection="row"
+        gap={1}
+        paddingLeft={2}
+        paddingRight={3}
+        paddingBottom={1}
+        backgroundColor={theme.backgroundElement}
+        justifyContent="space-between"
+      >
+        <box flexDirection="row" gap={1}>
+          <For each={keys}>
+            {(option) => (
+              <box
+                paddingLeft={1}
+                paddingRight={1}
+                backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
+              >
+                <text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
+                  {props.options[option]}
+                </text>
+              </box>
+            )}
+          </For>
+        </box>
+        <box flexDirection="row" gap={2}>
+          <text fg={theme.text}>
+            {"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
+          </text>
+          <text fg={theme.text}>
+            enter <span style={{ fg: theme.textMuted }}>confirm</span>
+          </text>
+        </box>
+      </box>
+    </box>
+  )
+}

+ 1 - 0
packages/opencode/src/cli/cmd/tui/ui/dialog.tsx

@@ -99,6 +99,7 @@ function init() {
     replace(input: any, onClose?: () => void) {
       if (store.stack.length === 0) {
         focus = renderer.currentFocusedRenderable
+        focus?.blur()
       }
       for (const item of store.stack) {
         if (item.onClose) item.onClose()

+ 88 - 23
packages/opencode/src/config/config.ts

@@ -368,7 +368,44 @@ export namespace Config {
   export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
   export type Mcp = z.infer<typeof Mcp>
 
-  export const Permission = z.enum(["ask", "allow", "deny"])
+  export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
+    ref: "PermissionActionConfig",
+  })
+  export type PermissionAction = z.infer<typeof PermissionAction>
+
+  export const PermissionObject = z.record(z.string(), PermissionAction).meta({
+    ref: "PermissionObjectConfig",
+  })
+  export type PermissionObject = z.infer<typeof PermissionObject>
+
+  export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
+    ref: "PermissionRuleConfig",
+  })
+  export type PermissionRule = z.infer<typeof PermissionRule>
+
+  export const Permission = z
+    .object({
+      read: PermissionRule.optional(),
+      edit: PermissionRule.optional(),
+      glob: PermissionRule.optional(),
+      grep: PermissionRule.optional(),
+      list: PermissionRule.optional(),
+      bash: PermissionRule.optional(),
+      task: PermissionRule.optional(),
+      external_directory: PermissionRule.optional(),
+      todowrite: PermissionAction.optional(),
+      todoread: PermissionAction.optional(),
+      webfetch: PermissionAction.optional(),
+      websearch: PermissionAction.optional(),
+      codesearch: PermissionAction.optional(),
+      doom_loop: PermissionAction.optional(),
+    })
+    .catchall(PermissionRule)
+    .or(PermissionAction)
+    .transform((x) => (typeof x === "string" ? { "*": x } : x))
+    .meta({
+      ref: "PermissionConfig",
+    })
   export type Permission = z.infer<typeof Permission>
 
   export const Command = z.object({
@@ -386,33 +423,70 @@ export namespace Config {
       temperature: z.number().optional(),
       top_p: z.number().optional(),
       prompt: z.string().optional(),
-      tools: z.record(z.string(), z.boolean()).optional(),
+      tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
       disable: z.boolean().optional(),
       description: z.string().optional().describe("Description of when to use the agent"),
       mode: z.enum(["subagent", "primary", "all"]).optional(),
+      options: z.record(z.string(), z.any()).optional(),
       color: z
         .string()
         .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
         .optional()
         .describe("Hex color code for the agent (e.g., #FF5733)"),
-      maxSteps: z
+      steps: z
         .number()
         .int()
         .positive()
         .optional()
         .describe("Maximum number of agentic iterations before forcing text-only response"),
-      permission: z
-        .object({
-          edit: Permission.optional(),
-          bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
-          skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
-          webfetch: Permission.optional(),
-          doom_loop: Permission.optional(),
-          external_directory: Permission.optional(),
-        })
-        .optional(),
+      maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
+      permission: Permission.optional(),
     })
     .catchall(z.any())
+    .transform((agent, ctx) => {
+      const knownKeys = new Set([
+        "model",
+        "prompt",
+        "description",
+        "temperature",
+        "top_p",
+        "mode",
+        "color",
+        "steps",
+        "maxSteps",
+        "options",
+        "permission",
+        "disable",
+        "tools",
+      ])
+
+      // Extract unknown properties into options
+      const options: Record<string, unknown> = { ...agent.options }
+      for (const [key, value] of Object.entries(agent)) {
+        if (!knownKeys.has(key)) options[key] = value
+      }
+
+      // Convert legacy tools config to permissions
+      const permission: Permission = { ...agent.permission }
+      for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
+        const action = enabled ? "allow" : "deny"
+        // write, edit, patch, multiedit all map to edit permission
+        if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+          permission.edit = action
+        } else {
+          permission[tool] = action
+        }
+      }
+
+      // Convert legacy maxSteps to steps
+      const steps = agent.steps ?? agent.maxSteps
+
+      return { ...agent, options, permission, steps } as typeof agent & {
+        options?: Record<string, unknown>
+        permission?: Permission
+        steps?: number
+      }
+    })
     .meta({
       ref: "AgentConfig",
     })
@@ -785,16 +859,7 @@ export namespace Config {
         ),
       instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
       layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
-      permission: z
-        .object({
-          edit: Permission.optional(),
-          bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
-          skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
-          webfetch: Permission.optional(),
-          doom_loop: Permission.optional(),
-          external_directory: Permission.optional(),
-        })
-        .optional(),
+      permission: Permission.optional(),
       tools: z.record(z.string(), z.boolean()).optional(),
       enterprise: z
         .object({

+ 162 - 0
packages/opencode/src/permission/arity.ts

@@ -0,0 +1,162 @@
+export namespace BashArity {
+  export function prefix(tokens: string[]) {
+    for (let len = tokens.length; len > 0; len--) {
+      const prefix = tokens.slice(0, len).join(" ")
+      const arity = ARITY[prefix]
+      if (arity !== undefined) return tokens.slice(0, arity)
+    }
+    return tokens
+  }
+
+  /* Generated with following prompt:
+You are generating a dictionary of command-prefix arities for bash-style commands.
+This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
+2. **Flags NEVER count as tokens**. Only subcommands count.
+3. **Longest matching prefix wins**.
+4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**.   * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
+5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
+6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed)
+* `git checkout main` → `git checkout` (because `git` has arity 2)
+* `npm install` → `npm install` (because `npm` has arity 2)
+* `npm run dev` → `npm run dev` (because `npm run` has arity 3)
+* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
+*/
+  const ARITY: Record<string, number> = {
+    cat: 1, // cat file.txt
+    cd: 1, // cd /path/to/dir
+    chmod: 1, // chmod 755 script.sh
+    chown: 1, // chown user:group file.txt
+    cp: 1, // cp source.txt dest.txt
+    echo: 1, // echo "hello world"
+    env: 1, // env
+    export: 1, // export PATH=/usr/bin
+    grep: 1, // grep pattern file.txt
+    kill: 1, // kill 1234
+    killall: 1, // killall process
+    ln: 1, // ln -s source target
+    ls: 1, // ls -la
+    mkdir: 1, // mkdir new-dir
+    mv: 1, // mv old.txt new.txt
+    ps: 1, // ps aux
+    pwd: 1, // pwd
+    rm: 1, // rm file.txt
+    rmdir: 1, // rmdir empty-dir
+    sleep: 1, // sleep 5
+    source: 1, // source ~/.bashrc
+    tail: 1, // tail -f log.txt
+    touch: 1, // touch file.txt
+    unset: 1, // unset VAR
+    which: 1, // which node
+    aws: 3, // aws s3 ls
+    az: 3, // az storage blob list
+    bazel: 2, // bazel build
+    brew: 2, // brew install node
+    bun: 2, // bun install
+    "bun run": 3, // bun run dev
+    "bun x": 3, // bun x vite
+    cargo: 2, // cargo build
+    "cargo add": 3, // cargo add tokio
+    "cargo run": 3, // cargo run main
+    cdk: 2, // cdk deploy
+    cf: 2, // cf push app
+    cmake: 2, // cmake build
+    composer: 2, // composer require laravel
+    consul: 2, // consul members
+    "consul kv": 3, // consul kv get config/app
+    crictl: 2, // crictl ps
+    deno: 2, // deno run server.ts
+    "deno task": 3, // deno task dev
+    doctl: 3, // doctl kubernetes cluster list
+    docker: 2, // docker run nginx
+    "docker builder": 3, // docker builder prune
+    "docker compose": 3, // docker compose up
+    "docker container": 3, // docker container ls
+    "docker image": 3, // docker image prune
+    "docker network": 3, // docker network inspect
+    "docker volume": 3, // docker volume ls
+    eksctl: 2, // eksctl get clusters
+    "eksctl create": 3, // eksctl create cluster
+    firebase: 2, // firebase deploy
+    flyctl: 2, // flyctl deploy
+    gcloud: 3, // gcloud compute instances list
+    gh: 3, // gh pr list
+    git: 2, // git checkout main
+    "git config": 3, // git config user.name
+    "git remote": 3, // git remote add origin
+    "git stash": 3, // git stash pop
+    go: 2, // go build
+    gradle: 2, // gradle build
+    helm: 2, // helm install mychart
+    heroku: 2, // heroku logs
+    hugo: 2, // hugo new site blog
+    ip: 2, // ip link show
+    "ip addr": 3, // ip addr show
+    "ip link": 3, // ip link set eth0 up
+    "ip netns": 3, // ip netns exec foo bash
+    "ip route": 3, // ip route add default via 1.1.1.1
+    kind: 2, // kind delete cluster
+    "kind create": 3, // kind create cluster
+    kubectl: 2, // kubectl get pods
+    "kubectl kustomize": 3, // kubectl kustomize overlays/dev
+    "kubectl rollout": 3, // kubectl rollout restart deploy/api
+    kustomize: 2, // kustomize build .
+    make: 2, // make build
+    mc: 2, // mc ls myminio
+    "mc admin": 3, // mc admin info myminio
+    minikube: 2, // minikube start
+    mongosh: 2, // mongosh test
+    mysql: 2, // mysql -u root
+    mvn: 2, // mvn compile
+    ng: 2, // ng generate component home
+    npm: 2, // npm install
+    "npm exec": 3, // npm exec vite
+    "npm init": 3, // npm init vue
+    "npm run": 3, // npm run dev
+    "npm view": 3, // npm view react version
+    nvm: 2, // nvm use 18
+    nx: 2, // nx build
+    openssl: 2, // openssl genrsa 2048
+    "openssl req": 3, // openssl req -new -key key.pem
+    "openssl x509": 3, // openssl x509 -in cert.pem
+    pip: 2, // pip install numpy
+    pipenv: 2, // pipenv install flask
+    pnpm: 2, // pnpm install
+    "pnpm dlx": 3, // pnpm dlx create-next-app
+    "pnpm exec": 3, // pnpm exec vite
+    "pnpm run": 3, // pnpm run dev
+    poetry: 2, // poetry add requests
+    podman: 2, // podman run alpine
+    "podman container": 3, // podman container ls
+    "podman image": 3, // podman image prune
+    psql: 2, // psql -d mydb
+    pulumi: 2, // pulumi up
+    "pulumi stack": 3, // pulumi stack output
+    pyenv: 2, // pyenv install 3.11
+    python: 2, // python -m venv env
+    rake: 2, // rake db:migrate
+    rbenv: 2, // rbenv install 3.2.0
+    "redis-cli": 2, // redis-cli ping
+    rustup: 2, // rustup update
+    serverless: 2, // serverless invoke
+    sfdx: 3, // sfdx force:org:list
+    skaffold: 2, // skaffold dev
+    sls: 2, // sls deploy
+    sst: 2, // sst deploy
+    swift: 2, // swift build
+    systemctl: 2, // systemctl restart nginx
+    terraform: 2, // terraform apply
+    "terraform workspace": 3, // terraform workspace select prod
+    tmux: 2, // tmux new -s dev
+    turbo: 2, // turbo run build
+    ufw: 2, // ufw allow 22
+    vault: 2, // vault login
+    "vault auth": 3, // vault auth list
+    "vault kv": 3, // vault kv get secret/api
+    vercel: 2, // vercel deploy
+    volta: 2, // volta install node
+    wp: 2, // wp plugin install
+    yarn: 2, // yarn add react
+    "yarn dlx": 3, // yarn dlx create-react-app
+    "yarn run": 3, // yarn run dev
+  }
+}

+ 4 - 3
packages/opencode/src/permission/index.ts

@@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
 import { Plugin } from "../plugin"
 import { Instance } from "../project/instance"
 import { Wildcard } from "../util/wildcard"
+import { Config } from "@/config/config"
 
 export namespace Permission {
   const log = Log.create({ service: "permission" })
@@ -27,7 +28,7 @@ export namespace Permission {
       sessionID: z.string(),
       messageID: z.string(),
       callID: z.string().optional(),
-      title: z.string(),
+      message: z.string(),
       metadata: z.record(z.string(), z.any()),
       time: z.object({
         created: z.number(),
@@ -99,7 +100,7 @@ export namespace Permission {
 
   export async function ask(input: {
     type: Info["type"]
-    title: Info["title"]
+    message: Info["message"]
     pattern?: Info["pattern"]
     callID?: Info["callID"]
     sessionID: Info["sessionID"]
@@ -123,7 +124,7 @@ export namespace Permission {
       sessionID: input.sessionID,
       messageID: input.messageID,
       callID: input.callID,
-      title: input.title,
+      message: input.message,
       metadata: input.metadata,
       time: {
         created: Date.now(),

+ 226 - 0
packages/opencode/src/permission/next.ts

@@ -0,0 +1,226 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Config } from "@/config/config"
+import { Identifier } from "@/id/id"
+import { Instance } from "@/project/instance"
+import { Storage } from "@/storage/storage"
+import { fn } from "@/util/fn"
+import { Log } from "@/util/log"
+import { Wildcard } from "@/util/wildcard"
+import z from "zod"
+
+export namespace PermissionNext {
+  const log = Log.create({ service: "permission" })
+
+  export const Action = z.enum(["allow", "deny", "ask"]).meta({
+    ref: "PermissionAction",
+  })
+  export type Action = z.infer<typeof Action>
+
+  export const Rule = z
+    .object({
+      permission: z.string(),
+      pattern: z.string(),
+      action: Action,
+    })
+    .meta({
+      ref: "PermissionRule",
+    })
+  export type Rule = z.infer<typeof Rule>
+
+  export const Ruleset = Rule.array().meta({
+    ref: "PermissionRuleset",
+  })
+  export type Ruleset = z.infer<typeof Ruleset>
+
+  export function fromConfig(permission: Config.Permission) {
+    const ruleset: Ruleset = []
+    for (const [key, value] of Object.entries(permission)) {
+      if (typeof value === "string") {
+        ruleset.push({
+          permission: key,
+          action: value,
+          pattern: "*",
+        })
+        continue
+      }
+      ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
+    }
+    return ruleset
+  }
+
+  export function merge(...rulesets: Ruleset[]): Ruleset {
+    return rulesets.flat()
+  }
+
+  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(),
+    })
+    .meta({
+      ref: "PermissionRequest",
+    })
+
+  export type Request = z.infer<typeof Request>
+
+  export const Reply = z.enum(["once", "always", "reject"])
+  export type Reply = z.infer<typeof Reply>
+
+  export const Approval = z.object({
+    projectID: z.string(),
+    patterns: z.string().array(),
+  })
+
+  export const Event = {
+    Asked: BusEvent.define("permission.next.asked", Request),
+    Replied: BusEvent.define(
+      "permission.next.replied",
+      z.object({
+        sessionID: z.string(),
+        requestID: z.string(),
+        reply: Reply,
+      }),
+    ),
+  }
+
+  const state = Instance.state(async () => {
+    const projectID = Instance.project.id
+    const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
+
+    const pending: Record<
+      string,
+      {
+        info: Request
+        resolve: () => void
+        reject: (e: any) => void
+      }
+    > = {}
+
+    return {
+      pending,
+      approved: stored,
+    }
+  })
+
+  export const ask = fn(
+    Request.partial({ id: true }).extend({
+      ruleset: Ruleset,
+    }),
+    async (input) => {
+      const s = await state()
+      const { ruleset, ...request } = input
+      for (const pattern of request.patterns ?? []) {
+        const action = evaluate(request.permission, pattern, ruleset, s.approved)
+        log.info("evaluated", { permission: request.permission, pattern, action })
+        if (action === "deny") throw new RejectedError()
+        if (action === "ask") {
+          const id = input.id ?? Identifier.ascending("permission")
+          return new Promise<void>((resolve, reject) => {
+            const info: Request = {
+              id,
+              ...request,
+            }
+            s.pending[id] = {
+              info,
+              resolve,
+              reject,
+            }
+            Bus.publish(Event.Asked, info)
+          })
+        }
+        if (action === "allow") continue
+      }
+    },
+  )
+
+  export const reply = fn(
+    z.object({
+      requestID: Identifier.schema("permission"),
+      reply: Reply,
+    }),
+    async (input) => {
+      const s = await state()
+      const existing = s.pending[input.requestID]
+      if (!existing) return
+      delete s.pending[input.requestID]
+      Bus.publish(Event.Replied, {
+        sessionID: existing.info.sessionID,
+        requestID: existing.info.id,
+        reply: input.reply,
+      })
+      if (input.reply === "reject") {
+        existing.reject(new RejectedError())
+        // Reject all other pending permissions for this session
+        const sessionID = existing.info.sessionID
+        for (const [id, pending] of Object.entries(s.pending)) {
+          if (pending.info.sessionID === sessionID) {
+            delete s.pending[id]
+            Bus.publish(Event.Replied, {
+              sessionID: pending.info.sessionID,
+              requestID: pending.info.id,
+              reply: "reject",
+            })
+            pending.reject(new RejectedError())
+          }
+        }
+        return
+      }
+      if (input.reply === "once") {
+        existing.resolve()
+        return
+      }
+      if (input.reply === "always") {
+        const projectID = Instance.project.id
+        for (const pattern of existing.info.always) {
+          s.approved.push({
+            permission: existing.info.permission,
+            pattern,
+            action: "allow",
+          })
+        }
+        await Storage.write(["permission", projectID], s.approved)
+        existing.resolve()
+        return
+      }
+    },
+  )
+
+  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Action {
+    const merged = merge(...rulesets)
+    log.info("evaluate", { permission, pattern, ruleset: merged })
+    const match = merged.findLast(
+      (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+    )
+    return match?.action ?? "ask"
+  }
+
+  const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
+
+  export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+    const result = new Set<string>()
+    for (const tool of tools) {
+      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+      if (evaluate(permission, "*", ruleset) === "deny") {
+        result.add(tool)
+      }
+    }
+    return result
+  }
+
+  export class RejectedError extends Error {
+    constructor(public readonly reason?: string) {
+      super(
+        reason !== undefined
+          ? reason
+          : `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
+      )
+    }
+  }
+}

+ 36 - 0
packages/opencode/src/server/server.ts

@@ -47,6 +47,7 @@ import { SessionStatus } from "@/session/status"
 import { upgradeWebSocket, websocket } from "hono/bun"
 import { errors } from "./error"
 import { Pty } from "@/pty"
+import { PermissionNext } from "@/permission/next"
 import { Installation } from "@/installation"
 import { MDNS } from "./mdns"
 
@@ -1558,6 +1559,41 @@ export namespace Server {
           return c.json(true)
         },
       )
+      .post(
+        "/permission/:requestID/reply",
+        describeRoute({
+          summary: "Respond to permission request",
+          description: "Approve or deny a permission request from the AI assistant.",
+          operationId: "permission.reply",
+          responses: {
+            200: {
+              description: "Permission processed successfully",
+              content: {
+                "application/json": {
+                  schema: resolver(z.boolean()),
+                },
+              },
+            },
+            ...errors(400, 404),
+          },
+        }),
+        validator(
+          "param",
+          z.object({
+            requestID: z.string(),
+          }),
+        ),
+        validator("json", z.object({ reply: PermissionNext.Reply })),
+        async (c) => {
+          const params = c.req.valid("param")
+          const json = c.req.valid("json")
+          await PermissionNext.reply({
+            requestID: params.requestID,
+            reply: json.reply,
+          })
+          return c.json(true)
+        },
+      )
       .get(
         "/permission",
         describeRoute({

+ 6 - 8
packages/opencode/src/session/llm.ts

@@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent"
 import type { MessageV2 } from "./message-v2"
 import { Plugin } from "@/plugin"
 import { SystemPrompt } from "./system"
-import { ToolRegistry } from "@/tool/registry"
 import { Flag } from "@/flag/flag"
+import { PermissionNext } from "@/permission/next"
 
 export namespace LLM {
   const log = Log.create({ service: "llm" })
@@ -200,13 +200,11 @@ export namespace LLM {
   }
 
   async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
-    const enabled = pipe(
-      input.agent.tools,
-      mergeDeep(await ToolRegistry.enabled(input.agent)),
-      mergeDeep(input.user.tools ?? {}),
-    )
-    for (const [key, value] of Object.entries(enabled)) {
-      if (value === false) delete input.tools[key]
+    const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
+    for (const tool of Object.keys(input.tools)) {
+      if (input.user.tools?.[tool] === false || disabled.has(tool)) {
+        delete input.tools[tool]
+      }
     }
     return input.tools
   }

+ 23 - 28
packages/opencode/src/session/processor.ts

@@ -14,6 +14,7 @@ import type { Provider } from "@/provider/provider"
 import { LLM } from "./llm"
 import { Config } from "@/config/config"
 import { SessionCompaction } from "./compaction"
+import { PermissionNext } from "@/permission/next"
 
 export namespace SessionProcessor {
   const DOOM_LOOP_THRESHOLD = 3
@@ -152,32 +153,19 @@ export namespace SessionProcessor {
                           JSON.stringify(p.state.input) === JSON.stringify(value.input),
                       )
                     ) {
-                      const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission)
-                      if (permission.doom_loop === "ask") {
-                        await Permission.ask({
-                          type: "doom_loop",
-                          pattern: value.toolName,
-                          sessionID: input.assistantMessage.sessionID,
-                          messageID: input.assistantMessage.id,
-                          callID: value.toolCallId,
-                          title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
-                          metadata: {
-                            tool: value.toolName,
-                            input: value.input,
-                          },
-                        })
-                      } else if (permission.doom_loop === "deny") {
-                        throw new Permission.RejectedError(
-                          input.assistantMessage.sessionID,
-                          "doom_loop",
-                          value.toolCallId,
-                          {
-                            tool: value.toolName,
-                            input: value.input,
-                          },
-                          `You seem to be stuck in a doom loop, please stop repeating the same action`,
-                        )
-                      }
+                      const agent = await Agent.get(input.assistantMessage.mode)
+                      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,
+                          input: value.input,
+                        },
+                        always: [value.toolName],
+                        ruleset: agent.permission,
+                      })
                     }
                   }
                   break
@@ -215,7 +203,11 @@ export namespace SessionProcessor {
                         status: "error",
                         input: value.input,
                         error: (value.error as any).toString(),
-                        metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
+                        metadata:
+                          value.error instanceof Permission.RejectedError ||
+                          value.error instanceof Permission.RejectedError
+                            ? value.error.metadata
+                            : undefined,
                         time: {
                           start: match.state.time.start,
                           end: Date.now(),
@@ -223,7 +215,10 @@ export namespace SessionProcessor {
                       },
                     })
 
-                    if (value.error instanceof Permission.RejectedError) {
+                    if (
+                      value.error instanceof Permission.RejectedError ||
+                      value.error instanceof PermissionNext.RejectedError
+                    ) {
                       blocked = shouldBreak
                     }
                     delete toolcalls[value.toolCallId]

+ 3 - 11
packages/opencode/src/session/prompt.ts

@@ -20,9 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
 import BUILD_SWITCH from "../session/prompt/build-switch.txt"
 import MAX_STEPS from "../session/prompt/max-steps.txt"
 import { defer } from "../util/defer"
-import { clone, mergeDeep, pipe } from "remeda"
+import { clone } from "remeda"
 import { ToolRegistry } from "../tool/registry"
-import { Wildcard } from "../util/wildcard"
 import { MCP } from "../mcp"
 import { LSP } from "../lsp"
 import { ReadTool } from "../tool/read"
@@ -473,7 +472,7 @@ export namespace SessionPrompt {
 
       // normal processing
       const agent = await Agent.get(lastUser.agent)
-      const maxSteps = agent.maxSteps ?? Infinity
+      const maxSteps = agent.steps ?? Infinity
       const isLastStep = step >= maxSteps
       msgs = insertReminders({
         messages: msgs,
@@ -587,13 +586,7 @@ export namespace SessionPrompt {
   }) {
     using _ = log.time("resolveTools")
     const tools: Record<string, AITool> = {}
-    const enabledTools = pipe(
-      input.agent.tools,
-      mergeDeep(await ToolRegistry.enabled(input.agent)),
-      mergeDeep(input.tools ?? {}),
-    )
-    for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
-      if (Wildcard.all(item.id, enabledTools) === false) continue
+    for (const item of await ToolRegistry.tools(input.model.providerID)) {
       const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
       tools[item.id] = tool({
         id: item.id as any,
@@ -656,7 +649,6 @@ export namespace SessionPrompt {
       })
     }
     for (const [key, item] of Object.entries(await MCP.tools())) {
-      if (Wildcard.all(key, enabledTools) === false) continue
       const execute = item.execute
       if (!execute) continue
 

+ 1 - 1
packages/opencode/src/session/system.ts

@@ -44,7 +44,7 @@ export namespace SystemPrompt {
         `</env>`,
         `<files>`,
         `  ${
-          project.vcs === "git"
+          project.vcs === "git" && false
             ? await Ripgrep.tree({
                 cwd: Instance.directory,
                 limit: 200,

+ 33 - 68
packages/opencode/src/tool/bash.ts

@@ -9,12 +9,11 @@ import { Language } from "web-tree-sitter"
 import { Agent } from "@/agent/agent"
 import { $ } from "bun"
 import { Filesystem } from "@/util/filesystem"
-import { Wildcard } from "@/util/wildcard"
-import { Permission } from "@/permission"
 import { fileURLToPath } from "url"
 import { Flag } from "@/flag/flag.ts"
-import path from "path"
 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
 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -83,39 +82,11 @@ export const BashTool = Tool.define("bash", async () => {
       }
       const agent = await Agent.get(ctx.agent)
 
-      const checkExternalDirectory = async (dir: string) => {
-        if (Filesystem.contains(Instance.directory, dir)) return
-        const title = `This command references paths outside of ${Instance.directory}`
-        if (agent.permission.external_directory === "ask") {
-          await Permission.ask({
-            type: "external_directory",
-            pattern: [dir, path.join(dir, "*")],
-            sessionID: ctx.sessionID,
-            messageID: ctx.messageID,
-            callID: ctx.callID,
-            title,
-            metadata: {
-              command: params.command,
-            },
-          })
-        } else if (agent.permission.external_directory === "deny") {
-          throw new Permission.RejectedError(
-            ctx.sessionID,
-            "external_directory",
-            ctx.callID,
-            {
-              command: params.command,
-            },
-            `${title} so this command is not allowed to be executed.`,
-          )
-        }
-      }
-
-      await checkExternalDirectory(cwd)
+      const directories = new Set<string>()
+      if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
+      const patterns = new Set<string>()
+      const always = new Set<string>()
 
-      const permissions = agent.permission.bash
-
-      const askPatterns = new Set<string>()
       for (const node of tree.rootNode.descendantsOfType("command")) {
         if (!node) continue
         const command = []
@@ -150,48 +121,42 @@ export const BashTool = Tool.define("bash", async () => {
                 process.platform === "win32" && resolved.match(/^\/[a-z]\//)
                   ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
                   : resolved
-
-              await checkExternalDirectory(normalized)
+              directories.add(normalized)
             }
           }
         }
 
-        // always allow cd if it passes above check
-        if (command[0] !== "cd") {
-          const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions)
-          if (action === "deny") {
-            throw new Error(
-              `The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`,
-            )
-          }
-          if (action === "ask") {
-            const pattern = (() => {
-              if (command.length === 0) return
-              const head = command[0]
-              // Find first non-flag argument as subcommand
-              const sub = command.slice(1).find((arg) => !arg.startsWith("-"))
-              return sub ? `${head} ${sub} *` : `${head} *`
-            })()
-            if (pattern) {
-              askPatterns.add(pattern)
-            }
-          }
+        // cd covered by above check
+        if (command.length && command[0] !== "cd") {
+          patterns.add(command.join(" "))
+          always.add(BashArity.prefix(command).join(" ") + "*")
         }
       }
 
-      if (askPatterns.size > 0) {
-        const patterns = Array.from(askPatterns)
-        await Permission.ask({
-          type: "bash",
-          pattern: patterns,
+      if (directories.size > 0) {
+        const dirs = Array.from(directories)
+        await PermissionNext.ask({
+          callID: ctx.callID,
+          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,
-          messageID: ctx.messageID,
+          metadata: {},
+          ruleset: agent.permission,
+        })
+      }
+
+      if (patterns.size > 0) {
+        await PermissionNext.ask({
           callID: ctx.callID,
-          title: params.command,
-          metadata: {
-            command: params.command,
-            patterns,
-          },
+          permission: "bash",
+          patterns: Array.from(patterns),
+          always: Array.from(always),
+          sessionID: ctx.sessionID,
+          message: params.command,
+          metadata: {},
+          ruleset: agent.permission,
         })
       }
 

+ 17 - 15
packages/opencode/src/tool/codesearch.ts

@@ -1,8 +1,8 @@
 import z from "zod"
 import { Tool } from "./tool"
 import DESCRIPTION from "./codesearch.txt"
-import { Config } from "../config/config"
-import { Permission } from "../permission"
+import { PermissionNext } from "@/permission/next"
+import { Agent } from "@/agent/agent"
 
 const API_CONFIG = {
   BASE_URL: "https://mcp.exa.ai",
@@ -52,19 +52,21 @@ export const CodeSearchTool = Tool.define("codesearch", {
       ),
   }),
   async execute(params, ctx) {
-    const cfg = await Config.get()
-    if (cfg.permission?.webfetch === "ask")
-      await Permission.ask({
-        type: "codesearch",
-        sessionID: ctx.sessionID,
-        messageID: ctx.messageID,
-        callID: ctx.callID,
-        title: "Search code for: " + params.query,
-        metadata: {
-          query: params.query,
-          tokensNum: params.tokensNum,
-        },
-      })
+    const agent = await Agent.get(ctx.agent)
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      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 = {
       jsonrpc: "2.0",

+ 42 - 52
packages/opencode/src/tool/edit.ts

@@ -8,7 +8,6 @@ import * as path from "path"
 import { Tool } from "./tool"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch, diffLines } from "diff"
-import { Permission } from "../permission"
 import DESCRIPTION from "./edit.txt"
 import { File } from "../file"
 import { Bus } from "../bus"
@@ -17,6 +16,7 @@ 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
 
@@ -46,31 +46,20 @@ export const EditTool = Tool.define("edit", {
     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)
-      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: `Edit file outside working directory: ${filePath}`,
-          metadata: {
-            filepath: 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`,
-        )
-      }
+      await PermissionNext.ask({
+        callID: ctx.callID,
+        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,
+      })
     }
 
     let diff = ""
@@ -80,19 +69,20 @@ export const EditTool = Tool.define("edit", {
       if (params.oldString === "") {
         contentNew = params.newString
         diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
-        if (agent.permission.edit === "ask") {
-          await Permission.ask({
-            type: "edit",
-            sessionID: ctx.sessionID,
-            messageID: ctx.messageID,
-            callID: ctx.callID,
-            title: "Edit this file: " + filePath,
-            metadata: {
-              filePath,
-              diff,
-            },
-          })
-        }
+        await PermissionNext.ask({
+          callID: ctx.callID,
+          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, {
           file: filePath,
@@ -112,19 +102,19 @@ export const EditTool = Tool.define("edit", {
       diff = trimDiff(
         createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
       )
-      if (agent.permission.edit === "ask") {
-        await Permission.ask({
-          type: "edit",
-          sessionID: ctx.sessionID,
-          messageID: ctx.messageID,
-          callID: ctx.callID,
-          title: "Edit this file: " + filePath,
-          metadata: {
-            filePath,
-            diff,
-          },
-        })
-      }
+      await PermissionNext.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)
       await Bus.publish(File.Event.Edited, {

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

@@ -4,6 +4,8 @@ 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,
@@ -16,7 +18,23 @@ export const GlobTool = Tool.define("glob", {
         `The directory to search in. If not specified, the current working directory will be used. 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.`,
       ),
   }),
-  async execute(params) {
+  async execute(params, ctx) {
+    const agent = await Agent.get(ctx.agent)
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      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
     search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
 

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

@@ -4,6 +4,8 @@ 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
 
@@ -14,11 +16,28 @@ export const GrepTool = Tool.define("grep", {
     path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
     include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
   }),
-  async execute(params) {
+  async execute(params, ctx) {
     if (!params.pattern) {
       throw new Error("pattern is required")
     }
 
+    const agent = await Agent.get(ctx.agent)
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      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
 
     const rgPath = await Ripgrep.filepath()

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

@@ -4,6 +4,8 @@ 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/",
@@ -40,9 +42,24 @@ export const ListTool = Tool.define("list", {
     path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
     ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
   }),
-  async execute(params) {
+  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,
+      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}`) || [])
     const files = []
     for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs })) {

+ 28 - 38
packages/opencode/src/tool/patch.ts

@@ -3,7 +3,6 @@ import * as path from "path"
 import * as fs from "fs/promises"
 import { Tool } from "./tool"
 import { FileTime } from "../file/time"
-import { Permission } from "../permission"
 import { Bus } from "../bus"
 import { FileWatcher } from "../file/watcher"
 import { Instance } from "../project/instance"
@@ -11,6 +10,7 @@ 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"),
@@ -55,31 +55,20 @@ export const PatchTool = Tool.define("patch", {
 
       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: `Patch file outside working directory: ${filePath}`,
-            metadata: {
-              filepath: 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`,
-          )
-        }
+        await PermissionNext.ask({
+          callID: ctx.callID,
+          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,
+        })
       }
 
       switch (hunk.type) {
@@ -152,18 +141,19 @@ export const PatchTool = Tool.define("patch", {
     }
 
     // Check permissions if needed
-    if (agent.permission.edit === "ask") {
-      await Permission.ask({
-        type: "edit",
-        sessionID: ctx.sessionID,
-        messageID: ctx.messageID,
-        callID: ctx.callID,
-        title: `Apply patch to ${fileChanges.length} files`,
-        metadata: {
-          diff: totalDiff,
-        },
-      })
-    }
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      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
     const changedFiles: string[] = []

+ 27 - 26
packages/opencode/src/tool/read.ts

@@ -8,9 +8,9 @@ import DESCRIPTION from "./read.txt"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
 import { Identifier } from "../id/id"
-import { Permission } from "../permission"
 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
@@ -32,33 +32,34 @@ export const ReadTool = Tool.define("read", {
 
     if (!ctx.extra?.["bypassCwdCheck"] && !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: `Access 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`,
-        )
-      }
+      await PermissionNext.ask({
+        callID: ctx.callID,
+        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,
+      permission: "read",
+      message: `Read file ${filepath}`,
+      patterns: [filepath],
+      always: ["*"],
+      sessionID: ctx.sessionID,
+      metadata: {},
+
+      ruleset: agent.permission,
+    })
+
     const block = iife(() => {
       const basename = path.basename(filepath)
       const whitelist = [".env.sample", ".env.example", ".example", ".env.template"]

+ 0 - 23
packages/opencode/src/tool/registry.ts

@@ -135,27 +135,4 @@ export namespace ToolRegistry {
     )
     return result
   }
-
-  export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
-    const result: Record<string, boolean> = {}
-
-    if (agent.permission.edit === "deny") {
-      result["edit"] = false
-      result["write"] = false
-    }
-    if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
-      result["bash"] = false
-    }
-    if (agent.permission.webfetch === "deny") {
-      result["webfetch"] = false
-      result["codesearch"] = false
-      result["websearch"] = false
-    }
-    // Disable skill tool if all skills are denied
-    if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
-      result["skill"] = false
-    }
-
-    return result
-  }
 }

+ 62 - 82
packages/opencode/src/tool/skill.ts

@@ -3,20 +3,14 @@ import z from "zod"
 import { Tool } from "./tool"
 import { Skill } from "../skill"
 import { Agent } from "../agent/agent"
-import { Permission } from "../permission"
-import { Wildcard } from "../util/wildcard"
 import { ConfigMarkdown } from "../config/markdown"
+import { PermissionNext } from "@/permission/next"
 
-const parameters = z.object({
-  name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
-})
-
-export const SkillTool: Tool.Info<typeof parameters> = {
-  id: "skill",
-  async init(ctx) {
-    const skills = await Skill.all()
+export const SkillTool = Tool.define("skill", async () => {
+  const skills = await Skill.all()
 
-    // Filter skills by agent permissions if agent provided
+  // Filter skills by agent permissions if agent provided
+  /*
     let accessibleSkills = skills
     if (ctx?.agent) {
       const permissions = ctx.agent.permission.skill
@@ -25,81 +19,67 @@ export const SkillTool: Tool.Info<typeof parameters> = {
         return action !== "deny"
       })
     }
+    */
 
-    const description =
-      accessibleSkills.length === 0
-        ? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
-        : [
-            "Load a skill to get detailed instructions for a specific task.",
-            "Skills provide specialized knowledge and step-by-step guidance.",
-            "Use this when a task matches an available skill's description.",
-            "<available_skills>",
-            ...accessibleSkills.flatMap((skill) => [
-              `  <skill>`,
-              `    <name>${skill.name}</name>`,
-              `    <description>${skill.description}</description>`,
-              `  </skill>`,
-            ]),
-            "</available_skills>",
-          ].join(" ")
-
-    return {
-      description,
-      parameters,
-      async execute(params, ctx) {
-        const agent = await Agent.get(ctx.agent)
-
-        const skill = await Skill.get(params.name)
+  const description =
+    skills.length === 0
+      ? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
+      : [
+          "Load a skill to get detailed instructions for a specific task.",
+          "Skills provide specialized knowledge and step-by-step guidance.",
+          "Use this when a task matches an available skill's description.",
+          "<available_skills>",
+          ...skills.flatMap((skill) => [
+            `  <skill>`,
+            `    <name>${skill.name}</name>`,
+            `    <description>${skill.description}</description>`,
+            `  </skill>`,
+          ]),
+          "</available_skills>",
+        ].join(" ")
 
-        if (!skill) {
-          const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
-          throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
-        }
+  return {
+    description,
+    parameters: z.object({
+      name: z
+        .string()
+        .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)
 
-        // Check permission using Wildcard.all on the skill name
-        const permissions = agent.permission.skill
-        const action = Wildcard.all(params.name, permissions)
+      const skill = await Skill.get(params.name)
 
-        if (action === "deny") {
-          throw new Permission.RejectedError(
-            ctx.sessionID,
-            "skill",
-            ctx.callID,
-            { skill: params.name },
-            `Access to skill "${params.name}" is denied for agent "${agent.name}".`,
-          )
-        }
+      if (!skill) {
+        const available = Skill.all().then((x) => Object.keys(x).join(", "))
+        throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
+      }
 
-        if (action === "ask") {
-          await Permission.ask({
-            type: "skill",
-            pattern: params.name,
-            sessionID: ctx.sessionID,
-            messageID: ctx.messageID,
-            callID: ctx.callID,
-            title: `Load skill: ${skill.name}`,
-            metadata: { name: skill.name, description: skill.description },
-          })
-        }
-
-        // Load and parse skill content
-        const parsed = await ConfigMarkdown.parse(skill.location)
-        const dir = path.dirname(skill.location)
+      await PermissionNext.ask({
+        callID: ctx.callID,
+        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)
+      const dir = path.dirname(skill.location)
 
-        // Format output similar to plugin pattern
-        const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join(
-          "\n",
-        )
+      // Format output similar to plugin pattern
+      const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
 
-        return {
-          title: `Loaded skill: ${skill.name}`,
-          output,
-          metadata: {
-            name: skill.name,
-            dir,
-          },
-        }
-      },
-    }
-  },
-}
+      return {
+        title: `Loaded skill: ${skill.name}`,
+        output,
+        metadata: {
+          name: skill.name,
+          dir,
+        },
+      }
+    },
+  }
+})

+ 16 - 0
packages/opencode/src/tool/task.ts

@@ -10,6 +10,7 @@ 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"))
@@ -29,6 +30,21 @@ 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,
+        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)
       if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
       const session = await iife(async () => {

+ 18 - 16
packages/opencode/src/tool/webfetch.ts

@@ -2,8 +2,8 @@ import z from "zod"
 import { Tool } from "./tool"
 import TurndownService from "turndown"
 import DESCRIPTION from "./webfetch.txt"
-import { Config } from "../config/config"
-import { Permission } from "../permission"
+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,20 +25,22 @@ export const WebFetchTool = Tool.define("webfetch", {
       throw new Error("URL must start with http:// or https://")
     }
 
-    const cfg = await Config.get()
-    if (cfg.permission?.webfetch === "ask")
-      await Permission.ask({
-        type: "webfetch",
-        sessionID: ctx.sessionID,
-        messageID: ctx.messageID,
-        callID: ctx.callID,
-        title: "Fetch content from: " + params.url,
-        metadata: {
-          url: params.url,
-          format: params.format,
-          timeout: params.timeout,
-        },
-      })
+    const agent = await Agent.get(ctx.agent)
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      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)
 

+ 20 - 18
packages/opencode/src/tool/websearch.ts

@@ -1,8 +1,8 @@
 import z from "zod"
 import { Tool } from "./tool"
 import DESCRIPTION from "./websearch.txt"
-import { Config } from "../config/config"
-import { Permission } from "../permission"
+import { PermissionNext } from "@/permission/next"
+import { Agent } from "@/agent/agent"
 
 const API_CONFIG = {
   BASE_URL: "https://mcp.exa.ai",
@@ -59,22 +59,24 @@ export const WebSearchTool = Tool.define("websearch", {
       .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
   }),
   async execute(params, ctx) {
-    const cfg = await Config.get()
-    if (cfg.permission?.webfetch === "ask")
-      await Permission.ask({
-        type: "websearch",
-        sessionID: ctx.sessionID,
-        messageID: ctx.messageID,
-        callID: ctx.callID,
-        title: "Search web for: " + params.query,
-        metadata: {
-          query: params.query,
-          numResults: params.numResults,
-          livecrawl: params.livecrawl,
-          type: params.type,
-          contextMaxCharacters: params.contextMaxCharacters,
-        },
-      })
+    const agent = await Agent.get(ctx.agent)
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      permission: "websearch",
+      message: "Search web for: " + params.query,
+      patterns: [params.query],
+      always: ["*"],
+      sessionID: ctx.sessionID,
+      metadata: {
+        query: params.query,
+        numResults: params.numResults,
+        livecrawl: params.livecrawl,
+        type: params.type,
+        contextMaxCharacters: params.contextMaxCharacters,
+      },
+
+      ruleset: agent.permission,
+    })
 
     const searchRequest: McpSearchRequest = {
       jsonrpc: "2.0",

+ 14 - 14
packages/opencode/src/tool/write.ts

@@ -2,7 +2,6 @@ import z from "zod"
 import * as path from "path"
 import { Tool } from "./tool"
 import { LSP } from "../lsp"
-import { Permission } from "../permission"
 import DESCRIPTION from "./write.txt"
 import { Bus } from "../bus"
 import { File } from "../file"
@@ -10,6 +9,7 @@ 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
@@ -24,6 +24,7 @@ export const WriteTool = Tool.define("write", {
     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") {
@@ -52,24 +53,23 @@ export const WriteTool = Tool.define("write", {
         )
       }
     }
+    */
 
     const file = Bun.file(filepath)
     const exists = await file.exists()
     if (exists) await FileTime.assert(ctx.sessionID, filepath)
 
-    if (agent.permission.edit === "ask")
-      await Permission.ask({
-        type: "write",
-        sessionID: ctx.sessionID,
-        messageID: ctx.messageID,
-        callID: ctx.callID,
-        title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
-        metadata: {
-          filePath: filepath,
-          content: params.content,
-          exists,
-        },
-      })
+    await PermissionNext.ask({
+      callID: ctx.callID,
+      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)
     await Bus.publish(File.Event.Edited, {

+ 385 - 83
packages/opencode/test/agent/agent.test.ts

@@ -1,11 +1,16 @@
 import { test, expect } from "bun:test"
-import path from "path"
-import fs from "fs/promises"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Agent } from "../../src/agent/agent"
+import { PermissionNext } from "../../src/permission/next"
 
-test("loads built-in agents when no custom agents configured", async () => {
+// Helper to evaluate permission for a tool with wildcard pattern
+function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
+  if (!agent) return undefined
+  return PermissionNext.evaluate(permission, "*", agent.permission)
+}
+
+test("returns default native agents when no config", async () => {
   await using tmp = await tmpdir()
   await Instance.provide({
     directory: tmp.path,
@@ -14,133 +19,430 @@ test("loads built-in agents when no custom agents configured", async () => {
       const names = agents.map((a) => a.name)
       expect(names).toContain("build")
       expect(names).toContain("plan")
+      expect(names).toContain("general")
+      expect(names).toContain("explore")
+      expect(names).toContain("compaction")
+      expect(names).toContain("title")
+      expect(names).toContain("summary")
+    },
+  })
+})
+
+test("build agent has correct default properties", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build).toBeDefined()
+      expect(build?.mode).toBe("primary")
+      expect(build?.native).toBe(true)
+      expect(evalPerm(build, "edit")).toBe("allow")
+      expect(evalPerm(build, "bash")).toBe("allow")
+    },
+  })
+})
+
+test("plan agent denies edits except .opencode/plan/*", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const plan = await Agent.get("plan")
+      expect(plan).toBeDefined()
+      // Wildcard is denied
+      expect(evalPerm(plan, "edit")).toBe("deny")
+      // But specific path is allowed
+      expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission)).toBe("allow")
+    },
+  })
+})
+
+test("explore agent denies edit and write", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const explore = await Agent.get("explore")
+      expect(explore).toBeDefined()
+      expect(explore?.mode).toBe("subagent")
+      expect(evalPerm(explore, "edit")).toBe("deny")
+      expect(evalPerm(explore, "write")).toBe("deny")
+      expect(evalPerm(explore, "todoread")).toBe("deny")
+      expect(evalPerm(explore, "todowrite")).toBe("deny")
+    },
+  })
+})
+
+test("general agent denies todo tools", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const general = await Agent.get("general")
+      expect(general).toBeDefined()
+      expect(general?.mode).toBe("subagent")
+      expect(general?.hidden).toBe(true)
+      expect(evalPerm(general, "todoread")).toBe("deny")
+      expect(evalPerm(general, "todowrite")).toBe("deny")
+    },
+  })
+})
+
+test("compaction agent denies all permissions", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const compaction = await Agent.get("compaction")
+      expect(compaction).toBeDefined()
+      expect(compaction?.hidden).toBe(true)
+      expect(evalPerm(compaction, "bash")).toBe("deny")
+      expect(evalPerm(compaction, "edit")).toBe("deny")
+      expect(evalPerm(compaction, "read")).toBe("deny")
     },
   })
 })
 
-test("custom subagent works alongside built-in primary agents", async () => {
+test("custom agent from config creates new agent", async () => {
   await using tmp = await tmpdir({
-    init: async (dir) => {
-      const opencodeDir = path.join(dir, ".opencode")
-      await fs.mkdir(opencodeDir, { recursive: true })
-      const agentDir = path.join(opencodeDir, "agent")
-      await fs.mkdir(agentDir, { recursive: true })
+    config: {
+      agent: {
+        my_custom_agent: {
+          model: "openai/gpt-4",
+          description: "My custom agent",
+          temperature: 0.5,
+          top_p: 0.9,
+        },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const custom = await Agent.get("my_custom_agent")
+      expect(custom).toBeDefined()
+      expect(custom?.model?.providerID).toBe("openai")
+      expect(custom?.model?.modelID).toBe("gpt-4")
+      expect(custom?.description).toBe("My custom agent")
+      expect(custom?.temperature).toBe(0.5)
+      expect(custom?.topP).toBe(0.9)
+      expect(custom?.native).toBe(false)
+      expect(custom?.mode).toBe("all")
+    },
+  })
+})
+
+test("custom agent config overrides native agent properties", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        build: {
+          model: "anthropic/claude-3",
+          description: "Custom build agent",
+          temperature: 0.7,
+          color: "#FF0000",
+        },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build).toBeDefined()
+      expect(build?.model?.providerID).toBe("anthropic")
+      expect(build?.model?.modelID).toBe("claude-3")
+      expect(build?.description).toBe("Custom build agent")
+      expect(build?.temperature).toBe(0.7)
+      expect(build?.color).toBe("#FF0000")
+      expect(build?.native).toBe(true)
+    },
+  })
+})
 
-      await Bun.write(
-        path.join(agentDir, "helper.md"),
-        `---
-model: test/model
-mode: subagent
----
-Helper subagent prompt`,
-      )
+test("agent disable removes agent from list", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        explore: { disable: true },
+      },
     },
   })
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
+      const explore = await Agent.get("explore")
+      expect(explore).toBeUndefined()
       const agents = await Agent.list()
-      const helper = agents.find((a) => a.name === "helper")
-      expect(helper).toBeDefined()
-      expect(helper?.mode).toBe("subagent")
+      const names = agents.map((a) => a.name)
+      expect(names).not.toContain("explore")
+    },
+  })
+})
 
-      // Built-in primary agents should still exist
-      const build = agents.find((a) => a.name === "build")
+test("agent permission config merges with defaults", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        build: {
+          permission: {
+            bash: {
+              "rm -rf *": "deny",
+            },
+          },
+        },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
       expect(build).toBeDefined()
-      expect(build?.mode).toBe("primary")
+      // Specific pattern is denied
+      expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission)).toBe("deny")
+      // Edit still allowed
+      expect(evalPerm(build, "edit")).toBe("allow")
+    },
+  })
+})
+
+test("global permission config applies to all agents", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      permission: {
+        bash: "deny",
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build).toBeDefined()
+      expect(evalPerm(build, "bash")).toBe("deny")
     },
   })
 })
 
-test("throws error when all primary agents are disabled", async () => {
+test("agent steps/maxSteps config sets steps property", async () => {
   await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        path.join(dir, "opencode.json"),
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          agent: {
-            build: { disable: true },
-            plan: { disable: true },
+    config: {
+      agent: {
+        build: { steps: 50 },
+        plan: { maxSteps: 100 },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      const plan = await Agent.get("plan")
+      expect(build?.steps).toBe(50)
+      expect(plan?.steps).toBe(100)
+    },
+  })
+})
+
+test("agent mode can be overridden", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        explore: { mode: "primary" },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const explore = await Agent.get("explore")
+      expect(explore?.mode).toBe("primary")
+    },
+  })
+})
+
+test("agent name can be overridden", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        build: { name: "Builder" },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build?.name).toBe("Builder")
+    },
+  })
+})
+
+test("agent prompt can be set from config", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        build: { prompt: "Custom system prompt" },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build?.prompt).toBe("Custom system prompt")
+    },
+  })
+})
+
+test("unknown agent properties are placed into options", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        build: {
+          random_property: "hello",
+          another_random: 123,
+        },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build?.options.random_property).toBe("hello")
+      expect(build?.options.another_random).toBe(123)
+    },
+  })
+})
+
+test("agent options merge correctly", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        build: {
+          options: {
+            custom_option: true,
+            another_option: "value",
           },
-        }),
-      )
+        },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(build?.options.custom_option).toBe(true)
+      expect(build?.options.another_option).toBe("value")
+    },
+  })
+})
+
+test("multiple custom agents can be defined", async () => {
+  await using tmp = await tmpdir({
+    config: {
+      agent: {
+        agent_a: {
+          description: "Agent A",
+          mode: "subagent",
+        },
+        agent_b: {
+          description: "Agent B",
+          mode: "primary",
+        },
+      },
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const agentA = await Agent.get("agent_a")
+      const agentB = await Agent.get("agent_b")
+      expect(agentA?.description).toBe("Agent A")
+      expect(agentA?.mode).toBe("subagent")
+      expect(agentB?.description).toBe("Agent B")
+      expect(agentB?.mode).toBe("primary")
+    },
+  })
+})
+
+test("Agent.get returns undefined for non-existent agent", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const nonExistent = await Agent.get("does_not_exist")
+      expect(nonExistent).toBeUndefined()
+    },
+  })
+})
+
+test("default permission includes doom_loop and external_directory as ask", async () => {
+  await using tmp = await tmpdir()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const build = await Agent.get("build")
+      expect(evalPerm(build, "doom_loop")).toBe("ask")
+      expect(evalPerm(build, "external_directory")).toBe("ask")
     },
   })
+})
+
+test("webfetch is allowed by default", async () => {
+  await using tmp = await tmpdir()
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      try {
-        await Agent.list()
-        expect(true).toBe(false) // should not reach here
-      } catch (e: any) {
-        expect(e.data?.message).toContain("No primary agents are available")
-      }
+      const build = await Agent.get("build")
+      expect(evalPerm(build, "webfetch")).toBe("allow")
     },
   })
 })
 
-test("does not throw when at least one primary agent remains", async () => {
+test("legacy tools config converts to permissions", async () => {
   await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        path.join(dir, "opencode.json"),
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          agent: {
-            build: { disable: true },
+    config: {
+      agent: {
+        build: {
+          tools: {
+            bash: false,
+            read: false,
           },
-        }),
-      )
+        },
+      },
     },
   })
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agents = await Agent.list()
-      const plan = agents.find((a) => a.name === "plan")
-      expect(plan).toBeDefined()
-      expect(plan?.mode).toBe("primary")
+      const build = await Agent.get("build")
+      expect(evalPerm(build, "bash")).toBe("deny")
+      expect(evalPerm(build, "read")).toBe("deny")
     },
   })
 })
 
-test("custom primary agent satisfies requirement when built-ins disabled", async () => {
+test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
   await using tmp = await tmpdir({
-    init: async (dir) => {
-      const opencodeDir = path.join(dir, ".opencode")
-      await fs.mkdir(opencodeDir, { recursive: true })
-      const agentDir = path.join(opencodeDir, "agent")
-      await fs.mkdir(agentDir, { recursive: true })
-
-      await Bun.write(
-        path.join(agentDir, "custom.md"),
-        `---
-model: test/model
-mode: primary
----
-Custom primary agent`,
-      )
-
-      await Bun.write(
-        path.join(dir, "opencode.json"),
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          agent: {
-            build: { disable: true },
-            plan: { disable: true },
+    config: {
+      agent: {
+        build: {
+          tools: {
+            write: false,
           },
-        }),
-      )
+        },
+      },
     },
   })
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agents = await Agent.list()
-      const custom = agents.find((a) => a.name === "custom")
-      expect(custom).toBeDefined()
-      expect(custom?.mode).toBe("primary")
+      const build = await Agent.get("build")
+      expect(evalPerm(build, "edit")).toBe("deny")
     },
   })
 })

+ 189 - 29
packages/opencode/test/config/config.test.ts

@@ -205,11 +205,13 @@ test("handles agent configuration", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.agent?.["test_agent"]).toEqual({
-        model: "test/model",
-        temperature: 0.7,
-        description: "test agent",
-      })
+      expect(config.agent?.["test_agent"]).toEqual(
+        expect.objectContaining({
+          model: "test/model",
+          temperature: 0.7,
+          description: "test agent",
+        }),
+      )
     },
   })
 })
@@ -292,6 +294,8 @@ test("migrates mode field to agent field", async () => {
         model: "test/model",
         temperature: 0.5,
         mode: "primary",
+        options: {},
+        permission: {},
       })
     },
   })
@@ -318,11 +322,13 @@ Test agent prompt`,
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.agent?.["test"]).toEqual({
-        name: "test",
-        model: "test/model",
-        prompt: "Test agent prompt",
-      })
+      expect(config.agent?.["test"]).toEqual(
+        expect.objectContaining({
+          name: "test",
+          model: "test/model",
+          prompt: "Test agent prompt",
+        }),
+      )
     },
   })
 })
@@ -534,13 +540,142 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
   })
 })
 
-test("compaction config defaults to true when not specified", async () => {
+// Legacy tools migration tests
+
+test("migrates legacy tools config to permissions - allow", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          agent: {
+            test: {
+              tools: {
+                bash: true,
+                read: true,
+              },
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        bash: "allow",
+        read: "allow",
+      })
+    },
+  })
+})
+
+test("migrates legacy tools config to permissions - deny", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          agent: {
+            test: {
+              tools: {
+                bash: false,
+                webfetch: false,
+              },
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        bash: "deny",
+        webfetch: "deny",
+      })
+    },
+  })
+})
+
+test("migrates legacy write tool to edit permission", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          agent: {
+            test: {
+              tools: {
+                write: true,
+              },
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        edit: "allow",
+      })
+    },
+  })
+})
+
+test("migrates legacy edit tool to edit permission", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          agent: {
+            test: {
+              tools: {
+                edit: false,
+              },
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        edit: "deny",
+      })
+    },
+  })
+})
+
+test("migrates legacy patch tool to edit permission", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "opencode.json"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
+          agent: {
+            test: {
+              tools: {
+                patch: true,
+              },
+            },
+          },
         }),
       )
     },
@@ -549,21 +684,26 @@ test("compaction config defaults to true when not specified", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      // When not specified, compaction should be undefined (defaults handled in usage)
-      expect(config.compaction).toBeUndefined()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        edit: "allow",
+      })
     },
   })
 })
 
-test("compaction config can disable auto compaction", async () => {
+test("migrates legacy multiedit tool to edit permission", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "opencode.json"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
-          compaction: {
-            auto: false,
+          agent: {
+            test: {
+              tools: {
+                multiedit: false,
+              },
+            },
           },
         }),
       )
@@ -573,21 +713,29 @@ test("compaction config can disable auto compaction", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.compaction?.auto).toBe(false)
-      expect(config.compaction?.prune).toBeUndefined()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        edit: "deny",
+      })
     },
   })
 })
 
-test("compaction config can disable prune", async () => {
+test("migrates mixed legacy tools config", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "opencode.json"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
-          compaction: {
-            prune: false,
+          agent: {
+            test: {
+              tools: {
+                bash: true,
+                write: true,
+                read: false,
+                webfetch: true,
+              },
+            },
           },
         }),
       )
@@ -597,22 +745,32 @@ test("compaction config can disable prune", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.compaction?.prune).toBe(false)
-      expect(config.compaction?.auto).toBeUndefined()
+      expect(config.agent?.["test"]?.permission).toEqual({
+        bash: "allow",
+        edit: "allow",
+        read: "deny",
+        webfetch: "allow",
+      })
     },
   })
 })
 
-test("compaction config can disable both auto and prune", async () => {
+test("merges legacy tools with existing permission config", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "opencode.json"),
         JSON.stringify({
           $schema: "https://opencode.ai/config.json",
-          compaction: {
-            auto: false,
-            prune: false,
+          agent: {
+            test: {
+              permission: {
+                glob: "allow",
+              },
+              tools: {
+                bash: true,
+              },
+            },
           },
         }),
       )
@@ -622,8 +780,10 @@ test("compaction config can disable both auto and prune", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.compaction?.auto).toBe(false)
-      expect(config.compaction?.prune).toBe(false)
+      expect(config.agent?.["test"]?.permission).toEqual({
+        glob: "allow",
+        bash: "allow",
+      })
     },
   })
 })

+ 11 - 0
packages/opencode/test/fixture/fixture.ts

@@ -2,6 +2,7 @@ import { $ } from "bun"
 import * as fs from "fs/promises"
 import os from "os"
 import path from "path"
+import type { Config } from "../../src/config/config"
 
 // Strip null bytes from paths (defensive fix for CI environment issues)
 function sanitizePath(p: string): string {
@@ -10,6 +11,7 @@ function sanitizePath(p: string): string {
 
 type TmpDirOptions<T> = {
   git?: boolean
+  config?: Partial<Config.Info>
   init?: (dir: string) => Promise<T>
   dispose?: (dir: string) => Promise<T>
 }
@@ -20,6 +22,15 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
     await $`git init`.cwd(dirpath).quiet()
     await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
   }
+  if (options?.config) {
+    await Bun.write(
+      path.join(dirpath, "opencode.json"),
+      JSON.stringify({
+        $schema: "https://opencode.ai/config.json",
+        ...options.config,
+      }),
+    )
+  }
   const extra = await options?.init?.(dirpath)
   const realpath = sanitizePath(await fs.realpath(dirpath))
   const result = {

+ 33 - 0
packages/opencode/test/permission/arity.test.ts

@@ -0,0 +1,33 @@
+import { test, expect } from "bun:test"
+import { BashArity } from "../../src/permission/arity"
+
+test("arity 1 - unknown commands default to first token", () => {
+  expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"])
+  expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"])
+})
+
+test("arity 2 - two token commands", () => {
+  expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"])
+  expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"])
+})
+
+test("arity 3 - three token commands", () => {
+  expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"])
+  expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"])
+})
+
+test("longest match wins - nested prefixes", () => {
+  expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"])
+  expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"])
+})
+
+test("exact length matches", () => {
+  expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"])
+  expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"])
+})
+
+test("edge cases", () => {
+  expect(BashArity.prefix([])).toEqual([])
+  expect(BashArity.prefix(["single"])).toEqual(["single"])
+  expect(BashArity.prefix(["git"])).toEqual(["git"])
+})

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

@@ -0,0 +1,663 @@
+import { test, expect } from "bun:test"
+import { PermissionNext } from "../../src/permission/next"
+import { Instance } from "../../src/project/instance"
+import { Storage } from "../../src/storage/storage"
+import { tmpdir } from "../fixture/fixture"
+
+// fromConfig tests
+
+test("fromConfig - string value becomes wildcard rule", () => {
+  const result = PermissionNext.fromConfig({ bash: "allow" })
+  expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
+})
+
+test("fromConfig - object value converts to rules array", () => {
+  const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
+  expect(result).toEqual([
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "bash", pattern: "rm", action: "deny" },
+  ])
+})
+
+test("fromConfig - mixed string and object values", () => {
+  const result = PermissionNext.fromConfig({
+    bash: { "*": "allow", rm: "deny" },
+    edit: "allow",
+    webfetch: "ask",
+  })
+  expect(result).toEqual([
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "bash", pattern: "rm", action: "deny" },
+    { permission: "edit", pattern: "*", action: "allow" },
+    { permission: "webfetch", pattern: "*", action: "ask" },
+  ])
+})
+
+test("fromConfig - empty object", () => {
+  const result = PermissionNext.fromConfig({})
+  expect(result).toEqual([])
+})
+
+// merge tests
+
+test("merge - simple concatenation", () => {
+  const result = PermissionNext.merge(
+    [{ permission: "bash", pattern: "*", action: "allow" }],
+    [{ permission: "bash", pattern: "*", action: "deny" }],
+  )
+  expect(result).toEqual([
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "bash", pattern: "*", action: "deny" },
+  ])
+})
+
+test("merge - adds new permission", () => {
+  const result = PermissionNext.merge(
+    [{ permission: "bash", pattern: "*", action: "allow" }],
+    [{ permission: "edit", pattern: "*", action: "deny" }],
+  )
+  expect(result).toEqual([
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "edit", pattern: "*", action: "deny" },
+  ])
+})
+
+test("merge - concatenates rules for same permission", () => {
+  const result = PermissionNext.merge(
+    [{ permission: "bash", pattern: "foo", action: "ask" }],
+    [{ permission: "bash", pattern: "*", action: "deny" }],
+  )
+  expect(result).toEqual([
+    { permission: "bash", pattern: "foo", action: "ask" },
+    { permission: "bash", pattern: "*", action: "deny" },
+  ])
+})
+
+test("merge - multiple rulesets", () => {
+  const result = PermissionNext.merge(
+    [{ permission: "bash", pattern: "*", action: "allow" }],
+    [{ permission: "bash", pattern: "rm", action: "ask" }],
+    [{ permission: "edit", pattern: "*", action: "allow" }],
+  )
+  expect(result).toEqual([
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "bash", pattern: "rm", action: "ask" },
+    { permission: "edit", pattern: "*", action: "allow" },
+  ])
+})
+
+test("merge - empty ruleset does nothing", () => {
+  const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
+  expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
+})
+
+test("merge - preserves rule order", () => {
+  const result = PermissionNext.merge(
+    [
+      { permission: "edit", pattern: "src/*", action: "allow" },
+      { permission: "edit", pattern: "src/secret/*", action: "deny" },
+    ],
+    [{ permission: "edit", pattern: "src/secret/ok.ts", action: "allow" }],
+  )
+  expect(result).toEqual([
+    { permission: "edit", pattern: "src/*", action: "allow" },
+    { permission: "edit", pattern: "src/secret/*", action: "deny" },
+    { permission: "edit", pattern: "src/secret/ok.ts", action: "allow" },
+  ])
+})
+
+test("merge - config permission overrides default ask", () => {
+  // Simulates: defaults have "*": "ask", config sets bash: "allow"
+  const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
+  const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const merged = PermissionNext.merge(defaults, config)
+
+  // Config's bash allow should override default ask
+  expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("allow")
+  // Other permissions should still be ask (from defaults)
+  expect(PermissionNext.evaluate("edit", "foo.ts", merged)).toBe("ask")
+})
+
+test("merge - config ask overrides default allow", () => {
+  // Simulates: defaults have bash: "allow", config sets bash: "ask"
+  const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
+  const merged = PermissionNext.merge(defaults, config)
+
+  // Config's ask should override default allow
+  expect(PermissionNext.evaluate("bash", "ls", merged)).toBe("ask")
+})
+
+// evaluate tests
+
+test("evaluate - exact pattern match", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard pattern match", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - last matching rule wins", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "bash", pattern: "rm", action: "deny" },
+  ])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - last matching rule wins (wildcard after specific)", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [
+    { permission: "bash", pattern: "rm", action: "deny" },
+    { permission: "bash", pattern: "*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - glob pattern match", () => {
+  const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+    { permission: "edit", pattern: "src/*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - last matching glob wins", () => {
+  const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+    { permission: "edit", pattern: "src/*", action: "deny" },
+    { permission: "edit", pattern: "src/components/*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - order matters for specificity", () => {
+  // If more specific rule comes first, later wildcard overrides it
+  const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+    { permission: "edit", pattern: "src/components/*", action: "allow" },
+    { permission: "edit", pattern: "src/*", action: "deny" },
+  ])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - unknown permission returns ask", () => {
+  const result = PermissionNext.evaluate("unknown_tool", "anything", [
+    { permission: "bash", pattern: "*", action: "allow" },
+  ])
+  expect(result).toBe("ask")
+})
+
+test("evaluate - empty ruleset returns ask", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [])
+  expect(result).toBe("ask")
+})
+
+test("evaluate - no matching pattern returns ask", () => {
+  const result = PermissionNext.evaluate("edit", "etc/passwd", [
+    { permission: "edit", pattern: "src/*", action: "allow" },
+  ])
+  expect(result).toBe("ask")
+})
+
+test("evaluate - empty rules array returns ask", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [])
+  expect(result).toBe("ask")
+})
+
+test("evaluate - multiple matching patterns, last wins", () => {
+  const result = PermissionNext.evaluate("edit", "src/secret.ts", [
+    { permission: "edit", pattern: "*", action: "ask" },
+    { permission: "edit", pattern: "src/*", action: "allow" },
+    { permission: "edit", pattern: "src/secret.ts", action: "deny" },
+  ])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - non-matching patterns are skipped", () => {
+  const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+    { permission: "edit", pattern: "*", action: "ask" },
+    { permission: "edit", pattern: "test/*", action: "deny" },
+    { permission: "edit", pattern: "src/*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - exact match at end wins over earlier wildcard", () => {
+  const result = PermissionNext.evaluate("bash", "/bin/rm", [
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "bash", pattern: "/bin/rm", action: "deny" },
+  ])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard at end overrides earlier exact match", () => {
+  const result = PermissionNext.evaluate("bash", "/bin/rm", [
+    { permission: "bash", pattern: "/bin/rm", action: "deny" },
+    { permission: "bash", pattern: "*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+// wildcard permission tests
+
+test("evaluate - wildcard permission matches any permission", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard permission with specific pattern", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - glob permission pattern", () => {
+  const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
+    { permission: "mcp_*", pattern: "*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - specific permission and wildcard permission combined", () => {
+  const result = PermissionNext.evaluate("bash", "rm", [
+    { permission: "*", pattern: "*", action: "deny" },
+    { permission: "bash", pattern: "*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - wildcard permission does not match when specific exists", () => {
+  const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+    { permission: "*", pattern: "*", action: "deny" },
+    { permission: "edit", pattern: "src/*", action: "allow" },
+  ])
+  expect(result).toBe("allow")
+})
+
+test("evaluate - multiple matching permission patterns combine rules", () => {
+  const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
+    { permission: "*", pattern: "*", action: "ask" },
+    { permission: "mcp_*", pattern: "*", action: "allow" },
+    { permission: "mcp_dangerous", pattern: "*", action: "deny" },
+  ])
+  expect(result).toBe("deny")
+})
+
+test("evaluate - wildcard permission fallback for unknown tool", () => {
+  const result = PermissionNext.evaluate("unknown_tool", "anything", [
+    { permission: "*", pattern: "*", action: "ask" },
+    { permission: "bash", pattern: "*", action: "allow" },
+  ])
+  expect(result).toBe("ask")
+})
+
+test("evaluate - permission patterns sorted by length regardless of object order", () => {
+  // specific permission listed before wildcard, but specific should still win
+  const result = PermissionNext.evaluate("bash", "rm", [
+    { permission: "bash", pattern: "*", action: "allow" },
+    { permission: "*", pattern: "*", action: "deny" },
+  ])
+  // With flat list, last matching rule wins - so "*" matches bash and wins
+  expect(result).toBe("deny")
+})
+
+test("evaluate - merges multiple rulesets", () => {
+  const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
+  // approved comes after config, so rm should be denied
+  const result = PermissionNext.evaluate("bash", "rm", config, approved)
+  expect(result).toBe("deny")
+})
+
+// disabled tests
+
+test("disabled - returns empty set when all tools allowed", () => {
+  const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
+  expect(result.size).toBe(0)
+})
+
+test("disabled - disables tool when denied", () => {
+  const result = PermissionNext.disabled(
+    ["bash", "edit", "read"],
+    [
+      { permission: "*", pattern: "*", action: "allow" },
+      { permission: "bash", pattern: "*", action: "deny" },
+    ],
+  )
+  expect(result.has("bash")).toBe(true)
+  expect(result.has("edit")).toBe(false)
+  expect(result.has("read")).toBe(false)
+})
+
+test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
+  const result = PermissionNext.disabled(
+    ["edit", "write", "patch", "multiedit", "bash"],
+    [
+      { permission: "*", pattern: "*", action: "allow" },
+      { permission: "edit", pattern: "*", action: "deny" },
+    ],
+  )
+  expect(result.has("edit")).toBe(true)
+  expect(result.has("write")).toBe(true)
+  expect(result.has("patch")).toBe(true)
+  expect(result.has("multiedit")).toBe(true)
+  expect(result.has("bash")).toBe(false)
+})
+
+test("disabled - does not disable when partially denied", () => {
+  const result = PermissionNext.disabled(
+    ["bash"],
+    [
+      { permission: "bash", pattern: "*", action: "allow" },
+      { permission: "bash", pattern: "rm *", action: "deny" },
+    ],
+  )
+  expect(result.has("bash")).toBe(false)
+})
+
+test("disabled - does not disable when action is ask", () => {
+  const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
+  expect(result.size).toBe(0)
+})
+
+test("disabled - disables when wildcard deny even with specific allow", () => {
+  // Tool is disabled because evaluate("bash", "*", ...) returns "deny"
+  // The "echo *" allow rule doesn't match the "*" pattern we're checking
+  const result = PermissionNext.disabled(
+    ["bash"],
+    [
+      { permission: "bash", pattern: "*", action: "deny" },
+      { permission: "bash", pattern: "echo *", action: "allow" },
+    ],
+  )
+  expect(result.has("bash")).toBe(true)
+})
+
+test("disabled - does not disable when wildcard allow after deny", () => {
+  const result = PermissionNext.disabled(
+    ["bash"],
+    [
+      { permission: "bash", pattern: "rm *", action: "deny" },
+      { permission: "bash", pattern: "*", action: "allow" },
+    ],
+  )
+  expect(result.has("bash")).toBe(false)
+})
+
+test("disabled - disables multiple tools", () => {
+  const result = PermissionNext.disabled(
+    ["bash", "edit", "webfetch"],
+    [
+      { permission: "bash", pattern: "*", action: "deny" },
+      { permission: "edit", pattern: "*", action: "deny" },
+      { permission: "webfetch", pattern: "*", action: "deny" },
+    ],
+  )
+  expect(result.has("bash")).toBe(true)
+  expect(result.has("edit")).toBe(true)
+  expect(result.has("webfetch")).toBe(true)
+})
+
+test("disabled - wildcard permission denies all tools", () => {
+  const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
+  expect(result.has("bash")).toBe(true)
+  expect(result.has("edit")).toBe(true)
+  expect(result.has("read")).toBe(true)
+})
+
+test("disabled - specific allow overrides wildcard deny", () => {
+  const result = PermissionNext.disabled(
+    ["bash", "edit", "read"],
+    [
+      { permission: "*", pattern: "*", action: "deny" },
+      { permission: "bash", pattern: "*", action: "allow" },
+    ],
+  )
+  expect(result.has("bash")).toBe(false)
+  expect(result.has("edit")).toBe(true)
+  expect(result.has("read")).toBe(true)
+})
+
+// ask tests
+
+test("ask - resolves immediately when action is allow", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const result = await PermissionNext.ask({
+        sessionID: "session_test",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls command",
+        metadata: {},
+        always: [],
+        ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
+      })
+      expect(result).toBeUndefined()
+    },
+  })
+})
+
+test("ask - throws RejectedError when action is deny", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await expect(
+        PermissionNext.ask({
+          sessionID: "session_test",
+          permission: "bash",
+          patterns: ["rm -rf /"],
+          message: "Run dangerous command",
+          metadata: {},
+          always: [],
+          ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
+        }),
+      ).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+    },
+  })
+})
+
+test("ask - returns pending promise when action is ask", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const promise = PermissionNext.ask({
+        sessionID: "session_test",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls command",
+        metadata: {},
+        always: [],
+        ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
+      })
+      // Promise should be pending, not resolved
+      expect(promise).toBeInstanceOf(Promise)
+      // Don't await - just verify it returns a promise
+    },
+  })
+})
+
+// reply tests
+
+test("reply - once resolves the pending ask", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const askPromise = PermissionNext.ask({
+        id: "permission_test1",
+        sessionID: "session_test",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls command",
+        metadata: {},
+        always: [],
+        ruleset: [],
+      })
+
+      await PermissionNext.reply({
+        requestID: "permission_test1",
+        reply: "once",
+      })
+
+      await expect(askPromise).resolves.toBeUndefined()
+    },
+  })
+})
+
+test("reply - reject throws RejectedError", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const askPromise = PermissionNext.ask({
+        id: "permission_test2",
+        sessionID: "session_test",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls command",
+        metadata: {},
+        always: [],
+        ruleset: [],
+      })
+
+      await PermissionNext.reply({
+        requestID: "permission_test2",
+        reply: "reject",
+      })
+
+      await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+    },
+  })
+})
+
+test("reply - always persists approval and resolves", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const askPromise = PermissionNext.ask({
+        id: "permission_test3",
+        sessionID: "session_test",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls command",
+        metadata: {},
+        always: ["ls"],
+        ruleset: [],
+      })
+
+      await PermissionNext.reply({
+        requestID: "permission_test3",
+        reply: "always",
+      })
+
+      await expect(askPromise).resolves.toBeUndefined()
+    },
+  })
+  // Re-provide to reload state with stored permissions
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      // Stored approval should allow without asking
+      const result = await PermissionNext.ask({
+        sessionID: "session_test2",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls command",
+        metadata: {},
+        always: [],
+        ruleset: [],
+      })
+      expect(result).toBeUndefined()
+    },
+  })
+})
+
+test("reply - reject cancels all pending for same session", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const askPromise1 = PermissionNext.ask({
+        id: "permission_test4a",
+        sessionID: "session_same",
+        permission: "bash",
+        patterns: ["ls"],
+        message: "Run ls",
+        metadata: {},
+        always: [],
+        ruleset: [],
+      })
+
+      const askPromise2 = PermissionNext.ask({
+        id: "permission_test4b",
+        sessionID: "session_same",
+        permission: "edit",
+        patterns: ["foo.ts"],
+        message: "Edit file",
+        metadata: {},
+        always: [],
+        ruleset: [],
+      })
+
+      // Catch rejections before they become unhandled
+      const result1 = askPromise1.catch((e) => e)
+      const result2 = askPromise2.catch((e) => e)
+
+      // Reject the first one
+      await PermissionNext.reply({
+        requestID: "permission_test4a",
+        reply: "reject",
+      })
+
+      // Both should be rejected
+      expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
+      expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
+    },
+  })
+})
+
+test("ask - checks all patterns and stops on first deny", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await expect(
+        PermissionNext.ask({
+          sessionID: "session_test",
+          permission: "bash",
+          patterns: ["echo hello", "rm -rf /"],
+          message: "Run commands",
+          metadata: {},
+          always: [],
+          ruleset: [
+            { permission: "bash", pattern: "*", action: "allow" },
+            { permission: "bash", pattern: "rm *", action: "deny" },
+          ],
+        }),
+      ).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+    },
+  })
+})
+
+test("ask - allows all patterns when all match allow rules", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const result = await PermissionNext.ask({
+        sessionID: "session_test",
+        permission: "bash",
+        patterns: ["echo hello", "ls -la", "pwd"],
+        message: "Run safe commands",
+        metadata: {},
+        always: [],
+        ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
+      })
+      expect(result).toBeUndefined()
+    },
+  })
+})

+ 39 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -55,6 +55,8 @@ import type {
   PartUpdateResponses,
   PathGetResponses,
   PermissionListResponses,
+  PermissionReplyErrors,
+  PermissionReplyResponses,
   PermissionRespondErrors,
   PermissionRespondResponses,
   ProjectCurrentResponses,
@@ -1626,6 +1628,43 @@ export class Permission extends HeyApiClient {
     })
   }
 
+  /**
+   * Respond to permission request
+   *
+   * Approve or deny a permission request from the AI assistant.
+   */
+  public reply<ThrowOnError extends boolean = false>(
+    parameters: {
+      requestID: string
+      directory?: string
+      reply?: "once" | "always" | "reject"
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "requestID" },
+            { in: "query", key: "directory" },
+            { in: "body", key: "reply" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<PermissionReplyResponses, PermissionReplyErrors, ThrowOnError>({
+      url: "/permission/{requestID}/reply",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
   /**
    * List pending permissions
    *

+ 157 - 114
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -451,6 +451,33 @@ 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>
+}
+
+export type EventPermissionNextAsked = {
+  type: "permission.next.asked"
+  properties: PermissionRequest
+}
+
+export type EventPermissionNextReplied = {
+  type: "permission.next.replied"
+  properties: {
+    sessionID: string
+    requestID: string
+    reply: "once" | "always" | "reject"
+  }
+}
+
 export type Permission = {
   id: string
   type: string
@@ -458,7 +485,7 @@ export type Permission = {
   sessionID: string
   messageID: string
   callID?: string
-  title: string
+  message: string
   metadata: {
     [key: string]: unknown
   }
@@ -481,40 +508,6 @@ export type EventPermissionReplied = {
   }
 }
 
-export type EventFileEdited = {
-  type: "file.edited"
-  properties: {
-    file: string
-  }
-}
-
-export type Todo = {
-  /**
-   * Brief description of the task
-   */
-  content: string
-  /**
-   * Current status of the task: pending, in_progress, completed, cancelled
-   */
-  status: string
-  /**
-   * Priority level of the task: high, medium, low
-   */
-  priority: string
-  /**
-   * Unique identifier for the todo item
-   */
-  id: string
-}
-
-export type EventTodoUpdated = {
-  type: "todo.updated"
-  properties: {
-    sessionID: string
-    todos: Array<Todo>
-  }
-}
-
 export type SessionStatus =
   | {
       type: "idle"
@@ -551,6 +544,40 @@ export type EventSessionCompacted = {
   }
 }
 
+export type EventFileEdited = {
+  type: "file.edited"
+  properties: {
+    file: string
+  }
+}
+
+export type Todo = {
+  /**
+   * Brief description of the task
+   */
+  content: string
+  /**
+   * Current status of the task: pending, in_progress, completed, cancelled
+   */
+  status: string
+  /**
+   * Priority level of the task: high, medium, low
+   */
+  priority: string
+  /**
+   * Unique identifier for the todo item
+   */
+  id: string
+}
+
+export type EventTodoUpdated = {
+  type: "todo.updated"
+  properties: {
+    sessionID: string
+    todos: Array<Todo>
+  }
+}
+
 export type EventTuiPromptAppend = {
   type: "tui.prompt.append"
   properties: {
@@ -756,13 +783,15 @@ export type Event =
   | EventMessageRemoved
   | EventMessagePartUpdated
   | EventMessagePartRemoved
+  | EventPermissionNextAsked
+  | EventPermissionNextReplied
   | EventPermissionUpdated
   | EventPermissionReplied
-  | EventFileEdited
-  | EventTodoUpdated
   | EventSessionStatus
   | EventSessionIdle
   | EventSessionCompacted
+  | EventFileEdited
+  | EventTodoUpdated
   | EventTuiPromptAppend
   | EventTuiCommandExecute
   | EventTuiToastShow
@@ -1183,11 +1212,42 @@ export type ServerConfig = {
   cors?: Array<string>
 }
 
+export type PermissionActionConfig = "ask" | "allow" | "deny"
+
+export type PermissionObjectConfig = {
+  [key: string]: PermissionActionConfig
+}
+
+export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig
+
+export type PermissionConfig =
+  | {
+      read?: PermissionRuleConfig
+      edit?: PermissionRuleConfig
+      glob?: PermissionRuleConfig
+      grep?: PermissionRuleConfig
+      list?: PermissionRuleConfig
+      bash?: PermissionRuleConfig
+      task?: PermissionRuleConfig
+      external_directory?: PermissionRuleConfig
+      todowrite?: PermissionActionConfig
+      todoread?: PermissionActionConfig
+      webfetch?: PermissionActionConfig
+      websearch?: PermissionActionConfig
+      codesearch?: PermissionActionConfig
+      doom_loop?: PermissionActionConfig
+      [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
+    }
+  | PermissionActionConfig
+
 export type AgentConfig = {
   model?: string
   temperature?: number
   top_p?: number
   prompt?: string
+  /**
+   * @deprecated Use 'permission' field instead
+   */
   tools?: {
     [key: string]: boolean
   }
@@ -1197,6 +1257,9 @@ export type AgentConfig = {
    */
   description?: string
   mode?: "subagent" | "primary" | "all"
+  options?: {
+    [key: string]: unknown
+  }
   /**
    * Hex color code for the agent (e.g., #FF5733)
    */
@@ -1204,27 +1267,12 @@ export type AgentConfig = {
   /**
    * Maximum number of agentic iterations before forcing text-only response
    */
+  steps?: number
+  /**
+   * @deprecated Use 'steps' field instead.
+   */
   maxSteps?: number
-  permission?: {
-    edit?: "ask" | "allow" | "deny"
-    bash?:
-      | "ask"
-      | "allow"
-      | "deny"
-      | {
-          [key: string]: "ask" | "allow" | "deny"
-        }
-    skill?:
-      | "ask"
-      | "allow"
-      | "deny"
-      | {
-          [key: string]: "ask" | "allow" | "deny"
-        }
-    webfetch?: "ask" | "allow" | "deny"
-    doom_loop?: "ask" | "allow" | "deny"
-    external_directory?: "ask" | "allow" | "deny"
-  }
+  permission?: PermissionConfig
   [key: string]:
     | unknown
     | string
@@ -1236,28 +1284,12 @@ export type AgentConfig = {
     | "subagent"
     | "primary"
     | "all"
-    | string
-    | number
     | {
-        edit?: "ask" | "allow" | "deny"
-        bash?:
-          | "ask"
-          | "allow"
-          | "deny"
-          | {
-              [key: string]: "ask" | "allow" | "deny"
-            }
-        skill?:
-          | "ask"
-          | "allow"
-          | "deny"
-          | {
-              [key: string]: "ask" | "allow" | "deny"
-            }
-        webfetch?: "ask" | "allow" | "deny"
-        doom_loop?: "ask" | "allow" | "deny"
-        external_directory?: "ask" | "allow" | "deny"
+        [key: string]: unknown
       }
+    | string
+    | number
+    | PermissionConfig
     | undefined
 }
 
@@ -1578,26 +1610,7 @@ export type Config = {
    */
   instructions?: Array<string>
   layout?: LayoutConfig
-  permission?: {
-    edit?: "ask" | "allow" | "deny"
-    bash?:
-      | "ask"
-      | "allow"
-      | "deny"
-      | {
-          [key: string]: "ask" | "allow" | "deny"
-        }
-    skill?:
-      | "ask"
-      | "allow"
-      | "deny"
-      | {
-          [key: string]: "ask" | "allow" | "deny"
-        }
-    webfetch?: "ask" | "allow" | "deny"
-    doom_loop?: "ask" | "allow" | "deny"
-    external_directory?: "ask" | "allow" | "deny"
-  }
+  permission?: PermissionConfig
   tools?: {
     [key: string]: boolean
   }
@@ -1880,40 +1893,35 @@ export type File = {
   status: "added" | "deleted" | "modified"
 }
 
+export type PermissionAction = "allow" | "deny" | "ask"
+
+export type PermissionRule = {
+  permission: string
+  pattern: string
+  action: PermissionAction
+}
+
+export type PermissionRuleset = Array<PermissionRule>
+
 export type Agent = {
   name: string
   description?: string
   mode: "subagent" | "primary" | "all"
   native?: boolean
   hidden?: boolean
-  default?: boolean
   topP?: number
   temperature?: number
   color?: string
-  permission: {
-    edit: "ask" | "allow" | "deny"
-    bash: {
-      [key: string]: "ask" | "allow" | "deny"
-    }
-    skill: {
-      [key: string]: "ask" | "allow" | "deny"
-    }
-    webfetch?: "ask" | "allow" | "deny"
-    doom_loop?: "ask" | "allow" | "deny"
-    external_directory?: "ask" | "allow" | "deny"
-  }
+  permission: PermissionRuleset
   model?: {
     modelID: string
     providerID: string
   }
   prompt?: string
-  tools: {
-    [key: string]: boolean
-  }
   options: {
     [key: string]: unknown
   }
-  maxSteps?: number
+  steps?: number
 }
 
 export type McpStatusConnected = {
@@ -3391,6 +3399,41 @@ export type PermissionRespondResponses = {
 
 export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
 
+export type PermissionReplyData = {
+  body?: {
+    reply: "once" | "always" | "reject"
+  }
+  path: {
+    requestID: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/permission/{requestID}/reply"
+}
+
+export type PermissionReplyErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+  /**
+   * Not found
+   */
+  404: NotFoundError
+}
+
+export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors]
+
+export type PermissionReplyResponses = {
+  /**
+   * Permission processed successfully
+   */
+  200: boolean
+}
+
+export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses]
+
 export type PermissionListData = {
   body?: never
   path?: never

+ 4 - 0
packages/sdk/openapi.json

@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 {
   "openapi": "3.1.1",
   "info": {
@@ -9750,3 +9751,6 @@
     }
   }
 }
+=======
+{}
+>>>>>>> 4f732c838 (feat: add command-aware permission request system for granular tool approval)