Просмотр исходного кода

Added: Ability to hide subagents from primary agents system prompt. (#4773)

Co-authored-by: GitHub Action <[email protected]>
Co-authored-by: Aiden Cline <[email protected]>
Co-authored-by: Aiden Cline <[email protected]>
Sewer. 3 месяцев назад
Родитель
Сommit
fd7b7eacd3

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

@@ -188,6 +188,7 @@ export namespace Agent {
       item.topP = value.top_p ?? item.topP
       item.mode = value.mode ?? item.mode
       item.color = value.color ?? item.color
+      item.hidden = value.hidden ?? item.hidden
       item.name = value.name ?? item.name
       item.steps = value.steps ?? item.steps
       item.options = mergeDeep(item.options, value.options ?? {})

+ 5 - 0
packages/opencode/src/config/config.ts

@@ -465,6 +465,10 @@ export namespace Config {
       disable: z.boolean().optional(),
       description: z.string().optional().describe("Description of when to use the agent"),
       mode: z.enum(["subagent", "primary", "all"]).optional(),
+      hidden: z
+        .boolean()
+        .optional()
+        .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
       options: z.record(z.string(), z.any()).optional(),
       color: z
         .string()
@@ -490,6 +494,7 @@ export namespace Config {
         "temperature",
         "top_p",
         "mode",
+        "hidden",
         "color",
         "steps",
         "maxSteps",

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

@@ -232,6 +232,7 @@ export namespace PermissionNext {
     const result = new Set<string>()
     for (const tool of tools) {
       const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+
       const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
       if (!rule) continue
       if (rule.pattern === "*" && rule.action === "deny") result.add(tool)

+ 44 - 5
packages/opencode/src/session/prompt.ts

@@ -37,7 +37,7 @@ import { SessionSummary } from "./summary"
 import { NamedError } from "@opencode-ai/util/error"
 import { fn } from "@/util/fn"
 import { SessionProcessor } from "./processor"
-import { TaskTool } from "@/tool/task"
+import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task"
 import { Tool } from "@/tool/tool"
 import { PermissionNext } from "@/permission/next"
 import { SessionStatus } from "./status"
@@ -382,7 +382,8 @@ export namespace SessionPrompt {
           messageID: assistantMessage.id,
           sessionID: sessionID,
           abort,
-          extra: { bypassAgentCheck: true },
+          callID: part.callID,
+          extra: { userInvokedAgents: [task.agent] },
           async metadata(input) {
             await Session.updatePart({
               ...part,
@@ -543,12 +544,20 @@ export namespace SessionPrompt {
         model,
         abort,
       })
+
+      // Track agents explicitly invoked by user via @ autocomplete
+      const userInvokedAgents = msgs
+        .filter((m) => m.info.role === "user")
+        .flatMap((m) => m.parts.filter((p) => p.type === "agent") as MessageV2.AgentPart[])
+        .map((p) => p.name)
+
       const tools = await resolveTools({
         agent,
         session,
         model,
         tools: lastUser.tools,
         processor,
+        userInvokedAgents,
       })
 
       if (step === 1) {
@@ -637,6 +646,7 @@ export namespace SessionPrompt {
     session: Session.Info
     tools?: Record<string, boolean>
     processor: SessionProcessor.Info
+    userInvokedAgents: string[]
   }) {
     using _ = log.time("resolveTools")
     const tools: Record<string, AITool> = {}
@@ -646,7 +656,7 @@ export namespace SessionPrompt {
       abort: options.abortSignal!,
       messageID: input.processor.message.id,
       callID: options.toolCallId,
-      extra: { model: input.model },
+      extra: { model: input.model, userInvokedAgents: input.userInvokedAgents },
       agent: input.agent.name,
       metadata: async (val: { title?: string; metadata?: any }) => {
         const match = input.processor.partFromToolCall(options.toolCallId)
@@ -789,6 +799,29 @@ export namespace SessionPrompt {
       }
       tools[key] = item
     }
+
+    // Regenerate task tool description with filtered subagents
+    if (tools.task) {
+      const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
+      const filtered = filterSubagents(all, input.agent.permission)
+
+      // If no subagents are permitted, remove the task tool entirely
+      if (filtered.length === 0) {
+        delete tools.task
+      } else {
+        const description = TASK_DESCRIPTION.replace(
+          "{agents}",
+          filtered
+            .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
+            .join("\n"),
+        )
+        tools.task = {
+          ...tools.task,
+          description,
+        }
+      }
+    }
+
     return tools
   }
 
@@ -1098,6 +1131,9 @@ export namespace SessionPrompt {
         }
 
         if (part.type === "agent") {
+          // Check if this agent would be denied by task permission
+          const perm = PermissionNext.evaluate("task", part.name, agent.permission)
+          const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
           return [
             {
               id: Identifier.ascending("part"),
@@ -1111,9 +1147,12 @@ export namespace SessionPrompt {
               sessionID: input.sessionID,
               type: "text",
               synthetic: true,
+              // An extra space is added here. Otherwise the 'Use' gets appended
+              // to user's last word; making a combined word
               text:
-                "Use the above message and context to generate a prompt and call the task tool with subagent: " +
-                part.name,
+                " Use the above message and context to generate a prompt and call the task tool with subagent: " +
+                part.name +
+                hint,
             },
           ]
         }

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

@@ -10,6 +10,13 @@ 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 { DESCRIPTION as TASK_DESCRIPTION }
+
+export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ruleset) {
+  return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny")
+}
 
 export const TaskTool = Tool.define("task", async () => {
   const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
@@ -30,8 +37,10 @@ export const TaskTool = Tool.define("task", async () => {
     }),
     async execute(params, ctx) {
       const config = await Config.get()
+
+      const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[]
       // Skip permission check when invoked from a command subtask (user already approved by invoking the command)
-      if (!ctx.extra?.bypassAgentCheck) {
+      if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) {
         await ctx.ask({
           permission: "task",
           patterns: [params.subagent_type],

+ 459 - 0
packages/opencode/test/permission-task.test.ts

@@ -0,0 +1,459 @@
+import { describe, test, expect } from "bun:test"
+import type { Agent } from "../src/agent/agent"
+import { filterSubagents } from "../src/tool/task"
+import { PermissionNext } from "../src/permission/next"
+import { Config } from "../src/config/config"
+import { Instance } from "../src/project/instance"
+import { tmpdir } from "./fixture/fixture"
+
+describe("filterSubagents - permission.task filtering", () => {
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+    Object.entries(rules).map(([pattern, action]) => ({
+      permission: "task",
+      pattern,
+      action,
+    }))
+
+  const mockAgents = [
+    { name: "general", mode: "subagent", permission: [], options: {} },
+    { name: "code-reviewer", mode: "subagent", permission: [], options: {} },
+    { name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
+    { name: "orchestrator-slow", mode: "subagent", permission: [], options: {} },
+  ] as Agent.Info[]
+
+  test("returns all agents when permissions config is empty", () => {
+    const result = filterSubagents(mockAgents, [])
+    expect(result).toHaveLength(4)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
+  })
+
+  test("excludes agents with explicit deny", () => {
+    const ruleset = createRuleset({ "code-reviewer": "deny" })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
+  })
+
+  test("includes agents with explicit allow", () => {
+    const ruleset = createRuleset({
+      "code-reviewer": "allow",
+      general: "deny",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
+  })
+
+  test("includes agents with ask permission (user approval is runtime behavior)", () => {
+    const ruleset = createRuleset({
+      "code-reviewer": "ask",
+      general: "deny",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
+  })
+
+  test("includes agents with undefined permission (default allow)", () => {
+    const ruleset = createRuleset({
+      general: "deny",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
+  })
+
+  test("supports wildcard patterns with deny", () => {
+    const ruleset = createRuleset({ "orchestrator-*": "deny" })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(2)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
+  })
+
+  test("supports wildcard patterns with allow", () => {
+    const ruleset = createRuleset({
+      "*": "allow",
+      "orchestrator-fast": "deny",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
+  })
+
+  test("supports wildcard patterns with ask", () => {
+    const ruleset = createRuleset({
+      "orchestrator-*": "ask",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(4)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
+  })
+
+  test("longer pattern takes precedence over shorter pattern", () => {
+    const ruleset = createRuleset({
+      "orchestrator-*": "deny",
+      "orchestrator-fast": "allow",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
+  })
+
+  test("edge case: all agents denied", () => {
+    const ruleset = createRuleset({ "*": "deny" })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(0)
+    expect(result).toEqual([])
+  })
+
+  test("edge case: mixed patterns with multiple wildcards", () => {
+    const ruleset = createRuleset({
+      "*": "ask",
+      "orchestrator-*": "deny",
+      "orchestrator-fast": "allow",
+    })
+    const result = filterSubagents(mockAgents, ruleset)
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
+  })
+
+  test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => {
+    const agents = [
+      { name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
+      { name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} },
+      { name: "orchestrator", mode: "subagent", permission: [], options: {} },
+    ] as Agent.Info[]
+
+    const result = filterSubagents(agents, [])
+    expect(result).toHaveLength(3)
+    expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"])
+  })
+
+  test("hidden: true agents can be filtered by permission.task deny", () => {
+    const agents = [
+      { name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
+      { name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} },
+    ] as Agent.Info[]
+
+    const ruleset = createRuleset({ general: "deny" })
+    const result = filterSubagents(agents, ruleset)
+    expect(result).toHaveLength(1)
+    expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"])
+  })
+})
+
+describe("PermissionNext.evaluate for permission.task", () => {
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+    Object.entries(rules).map(([pattern, action]) => ({
+      permission: "task",
+      pattern,
+      action,
+    }))
+
+  test("returns ask when no match (default)", () => {
+    expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
+  })
+
+  test("returns deny for explicit deny", () => {
+    const ruleset = createRuleset({ "code-reviewer": "deny" })
+    expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+  })
+
+  test("returns allow for explicit allow", () => {
+    const ruleset = createRuleset({ "code-reviewer": "allow" })
+    expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
+  })
+
+  test("returns ask for explicit ask", () => {
+    const ruleset = createRuleset({ "code-reviewer": "ask" })
+    expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
+  })
+
+  test("matches wildcard patterns with deny", () => {
+    const ruleset = createRuleset({ "orchestrator-*": "deny" })
+    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
+    expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
+    expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
+  })
+
+  test("matches wildcard patterns with allow", () => {
+    const ruleset = createRuleset({ "orchestrator-*": "allow" })
+    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+    expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
+  })
+
+  test("matches wildcard patterns with ask", () => {
+    const ruleset = createRuleset({ "orchestrator-*": "ask" })
+    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
+    const globalRuleset = createRuleset({ "*": "ask" })
+    expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
+  })
+
+  test("later rules take precedence (last match wins)", () => {
+    const ruleset = createRuleset({
+      "orchestrator-*": "deny",
+      "orchestrator-fast": "allow",
+    })
+    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+    expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
+  })
+
+  test("matches global wildcard", () => {
+    expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
+    expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
+    expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
+  })
+})
+
+describe("PermissionNext.disabled for task tool", () => {
+  // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
+  // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
+  // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+    Object.entries(rules).map(([pattern, action]) => ({
+      permission: "task",
+      pattern,
+      action,
+    }))
+
+  test("task tool is disabled when global deny pattern exists (even with specific allows)", () => {
+    // When "*": "deny" exists, the task tool is disabled because the disabled() function
+    // only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed
+    const ruleset = createRuleset({
+      "orchestrator-*": "allow",
+      "*": "deny",
+    })
+    const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
+    // The task tool IS disabled because there's a pattern: "*" with action: "deny"
+    expect(disabled.has("task")).toBe(true)
+  })
+
+  test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => {
+    const ruleset = createRuleset({
+      "orchestrator-*": "ask",
+      "*": "deny",
+    })
+    const disabled = PermissionNext.disabled(["task"], ruleset)
+    // The task tool IS disabled because there's a pattern: "*" with action: "deny"
+    expect(disabled.has("task")).toBe(true)
+  })
+
+  test("task tool is disabled when global deny pattern exists", () => {
+    const ruleset = createRuleset({ "*": "deny" })
+    const disabled = PermissionNext.disabled(["task"], ruleset)
+    expect(disabled.has("task")).toBe(true)
+  })
+
+  test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => {
+    // The disabled() function only disables tools when pattern: "*" && action: "deny"
+    // Specific subagent denies don't disable the task tool - those are handled at runtime
+    const ruleset = createRuleset({
+      "orchestrator-*": "deny",
+      general: "deny",
+    })
+    const disabled = PermissionNext.disabled(["task"], ruleset)
+    // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
+    expect(disabled.has("task")).toBe(false)
+  })
+
+  test("task tool is enabled when no task rules exist (default ask)", () => {
+    const disabled = PermissionNext.disabled(["task"], [])
+    expect(disabled.has("task")).toBe(false)
+  })
+
+  test("task tool is NOT disabled when last wildcard pattern is allow", () => {
+    // Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled
+    const ruleset = createRuleset({
+      "*": "deny",
+      "orchestrator-coder": "allow",
+    })
+    const disabled = PermissionNext.disabled(["task"], ruleset)
+    // The disabled() function uses findLast and checks if the last matching rule
+    // has pattern: "*" and action: "deny". In this case, the last rule matching
+    // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
+    expect(disabled.has("task")).toBe(false)
+  })
+})
+
+// Integration tests that load permissions from real config files
+describe("permission.task with real config files", () => {
+  const mockAgents = [
+    { name: "general", mode: "subagent", permission: [], options: {} },
+    { name: "code-reviewer", mode: "subagent", permission: [], options: {} },
+    { name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
+  ] as Agent.Info[]
+
+  test("loads task permissions from opencode.json config", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        permission: {
+          task: {
+            "*": "allow",
+            "code-reviewer": "deny",
+          },
+        },
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const result = filterSubagents(mockAgents, ruleset)
+        expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"])
+      },
+    })
+  })
+
+  test("loads task permissions with wildcard patterns from config", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        permission: {
+          task: {
+            "*": "ask",
+            "orchestrator-*": "deny",
+          },
+        },
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const result = filterSubagents(mockAgents, ruleset)
+        expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
+      },
+    })
+  })
+
+  test("evaluate respects task permission from config", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        permission: {
+          task: {
+            general: "allow",
+            "code-reviewer": "deny",
+          },
+        },
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
+        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        // Unspecified agents default to "ask"
+        expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
+      },
+    })
+  })
+
+  test("mixed permission config with task and other tools", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        permission: {
+          bash: "allow",
+          edit: "ask",
+          task: {
+            "*": "deny",
+            general: "allow",
+          },
+        },
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+
+        // Verify task permissions
+        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
+        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+
+        // Verify other tool permissions
+        expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
+        expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
+
+        // Verify disabled tools
+        const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
+        expect(disabled.has("bash")).toBe(false)
+        expect(disabled.has("edit")).toBe(false)
+        // task is NOT disabled because disabled() uses findLast, and the last rule
+        // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*"
+        expect(disabled.has("task")).toBe(false)
+      },
+    })
+  })
+
+  test("task tool disabled when global deny comes last in config", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        permission: {
+          task: {
+            general: "allow",
+            "code-reviewer": "allow",
+            "*": "deny",
+          },
+        },
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+
+        // Last matching rule wins - "*" deny is last, so all agents are denied
+        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
+        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
+
+        // Since "*": "deny" is the last rule, disabled() finds it with findLast
+        // and sees pattern: "*" with action: "deny", so task is disabled
+        const disabled = PermissionNext.disabled(["task"], ruleset)
+        expect(disabled.has("task")).toBe(true)
+      },
+    })
+  })
+
+  test("task tool NOT disabled when specific allow comes last in config", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        permission: {
+          task: {
+            "*": "deny",
+            general: "allow",
+          },
+        },
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+
+        // Evaluate uses findLast - "general" allow comes after "*" deny
+        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
+        // Other agents still denied by the earlier "*" deny
+        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+
+        // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
+        // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
+        // So the task tool is NOT disabled (even though most subagents are denied)
+        const disabled = PermissionNext.disabled(["task"], ruleset)
+        expect(disabled.has("task")).toBe(false)
+      },
+    })
+  })
+})

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

@@ -1259,6 +1259,10 @@ export type AgentConfig = {
    */
   description?: string
   mode?: "subagent" | "primary" | "all"
+  /**
+   * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)
+   */
+  hidden?: boolean
   options?: {
     [key: string]: unknown
   }

+ 56 - 0
packages/web/src/content/docs/agents.mdx

@@ -510,6 +510,62 @@ The `mode` option can be set to `primary`, `subagent`, or `all`. If no `mode` is
 
 ---
 
+### Hidden
+
+Hide a subagent from the `@` autocomplete menu with `hidden: true`. Useful for internal subagents that should only be invoked programmatically by other agents via the Task tool.
+
+```json title="opencode.json"
+{
+  "agent": {
+    "internal-helper": {
+      "mode": "subagent",
+      "hidden": true
+    }
+  }
+}
+```
+
+This only affects user visibility in the autocomplete menu. Hidden agents can still be invoked by the model via the Task tool if permissions allow.
+
+:::note
+Only applies to `mode: subagent` agents.
+:::
+
+---
+
+### Task permissions
+
+Control which subagents an agent can invoke via the Task tool with `permission.task`. Uses glob patterns for flexible matching.
+
+```json title="opencode.json"
+{
+  "agent": {
+    "orchestrator": {
+      "mode": "primary",
+      "permission": {
+        "task": {
+          "*": "deny",
+          "orchestrator-*": "allow",
+          "code-reviewer": "ask"
+        }
+      }
+    }
+  }
+}
+```
+
+When set to `deny`, the subagent is removed from the Task tool description entirely, so the model won't attempt to invoke it.
+
+:::tip
+Rules are evaluated in order, and the **last matching rule wins**. In the example above, `orchestrator-planner` matches both `*` (deny) and `orchestrator-*` (allow), but since `orchestrator-*` comes after `*`, the result is `allow`.
+:::
+
+:::tip
+Users can always invoke any subagent directly via the `@` autocomplete menu, even if the agent's task permissions would deny it.
+:::
+
+---
+
 ### Additional
 
 Any other options you specify in your agent configuration will be **passed through directly** to the provider as model options. This allows you to use provider-specific features and parameters.