summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorSewer. <[email protected]>2026-01-07 04:29:17 +0000
committerGitHub <[email protected]>2026-01-06 22:29:17 -0600
commitfd7b7eacd3c2da70021ec3ed4905d072abd20b56 (patch)
tree6b47bd4b38caad9e88dc474d6e8fed90999ad6c8 /packages
parenteaa0826e7fa3aa5a52d670dad0dce92108843700 (diff)
downloadopencode-fd7b7eacd3c2da70021ec3ed4905d072abd20b56.tar.gz
opencode-fd7b7eacd3c2da70021ec3ed4905d072abd20b56.zip
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]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/agent/agent.ts1
-rw-r--r--packages/opencode/src/config/config.ts5
-rw-r--r--packages/opencode/src/permission/next.ts1
-rw-r--r--packages/opencode/src/session/prompt.ts49
-rw-r--r--packages/opencode/src/tool/task.ts11
-rw-r--r--packages/opencode/test/permission-task.test.ts459
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts4
-rw-r--r--packages/web/src/content/docs/agents.mdx56
8 files changed, 580 insertions, 6 deletions
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 218591866..65e574581 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/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 ?? {})
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 130031f02..178526bbc 100644
--- a/packages/opencode/src/config/config.ts
+++ b/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",
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts
index 9a0395fa1..f95aaf345 100644
--- a/packages/opencode/src/permission/next.ts
+++ b/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)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 0306c59eb..09155c86e 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/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,
},
]
}
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 3489f3bf7..a30a5a675 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/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],
diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts
new file mode 100644
index 000000000..21a039d12
--- /dev/null
+++ b/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)
+ },
+ })
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index fb3be8208..97a695162 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/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
}
diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx
index f3f0b52eb..3dfd16e7d 100644
--- a/packages/web/src/content/docs/agents.mdx
+++ b/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.