Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,9 +1316,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
if (permissions.length > 0) {
session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
const next = [
...(session.permission ?? []).filter(
(item) => !permissions.some((rule) => rule.permission === item.permission && rule.pattern === item.pattern),
),
...permissions,
]
if (next.length > 0) {
session.permission = next
yield* sessions.setPermission({ sessionID: session.id, permission: next })
}

if (input.noReply === true) return message
Expand Down
36 changes: 35 additions & 1 deletion packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])

function trim(input: string) {
return input.replace(/^['"]|['"]$/g, "")
}

function redirect(input: string) {
return /(^|[^<])>>?|&>|>&|<>/.test(input)
}

const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
Expand All @@ -75,6 +83,7 @@ type Scan = {
dirs: Set<string>
patterns: Set<string>
always: Set<string>
edits?: Set<string>
}

export const log = Log.create({ service: "bash-tool" })
Expand Down Expand Up @@ -244,12 +253,28 @@ async function collect(root: Node, cwd: string, ps: boolean, shell: string): Pro
patterns: new Set<string>(),
always: new Set<string>(),
}
const edits = new Set<string>()

for (const node of commands(root)) {
const command = parts(node)
const commandText = command.map((item) => item.text).join(" ")
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]

if (node.parent?.type === "redirected_statement" && redirect(commandText)) {
edits.add("*")
}
if (command[0] === "sed" && command.includes("-i")) {
const arg = command.at(-1)
if (!arg || arg.startsWith("-")) {
edits.add("*")
} else {
const file = path.resolve(cwd, trim(arg))
if (!Instance.containsPath(file)) edits.add("*")
else edits.add(path.relative(Instance.worktree, file))
}
}

if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = await argPath(arg, cwd, ps, shell)
Expand All @@ -266,7 +291,7 @@ async function collect(root: Node, cwd: string, ps: boolean, shell: string): Pro
}
}

return scan
return { ...scan, edits: edits.size > 0 ? edits : undefined }
}

function preview(text: string) {
Expand Down Expand Up @@ -294,6 +319,15 @@ async function ask(ctx: Tool.Context, scan: Scan) {
})
}

if (scan.edits?.size) {
await ctx.ask({
permission: "edit",
patterns: Array.from(scan.edits),
always: ["*"],
metadata: {},
})
}

if (scan.patterns.size === 0) return
await ctx.ask({
permission: "bash",
Expand Down
129 changes: 104 additions & 25 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,31 @@ import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"

const id = "task"

const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})

export const TaskTool = Tool.defineEffect(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
const agentList = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")

const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
return {
description,
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get()
const caller = await Agent.get(ctx.agent)

if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() =>
Expand All @@ -48,10 +50,87 @@ export const TaskTool = Tool.defineEffect(
)
}

const next = yield* agent.get(params.subagent_type)
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
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`)
if (!caller) throw new Error(`Unknown agent type: ${ctx.agent}`)

const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
const parent = await Session.get(ctx.sessionID)
const rules = Permission.merge(caller.permission, parent.permission ?? [])
const perm = [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
{
permission: "todoread" as const,
pattern: "*" as const,
action: "deny" as const,
},
...(hasTaskPermission
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(Permission.evaluate("edit", "*", rules).action === "deny"
? [
{
permission: "edit" as const,
pattern: "*" as const,
action: "deny" as const,
},
]
: []),
]

const session = await iife(async () => {
if (params.task_id) {
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}

return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
...perm,
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
permission: t,
})) ?? []),
],
})
})
const curr = session.permission ?? []
const next = [
...curr.filter(
(item) => !perm.some((rule) => rule.permission === item.permission && rule.pattern === item.pattern),
),
...perm,
]
const same =
next.length === curr.length &&
next.every(
(rule, i) =>
rule.permission === curr[i]?.permission &&
rule.pattern === curr[i]?.pattern &&
rule.action === curr[i]?.action,
)
if (!same) {
session.permission = next
await Session.setPermission({
sessionID: session.id,
permission: next,
})
}
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")

const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
Expand Down
Loading