Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode-ai/ai"import type { Context } from "@opencode-ai/plugin/effect/plugin"import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"import type { Tool } from "@opencode-ai/schema/tool"import { Deferred, Effect, Schema, Scope } from "effect"import { Config } from "../../config.js"import { Environment } from "../../environment/index.js"import { LocationMutation } from "../../location-mutation.js"import { Permission } from "../../permission.js"import { PluginRuntime } from "../../plugin/runtime.js"import { NonNegativeInt } from "../../schema.js"import { SessionSchema } from "../../session/schema.js"import { Shell } from "../../shell.js"import { ShellParse } from "../../shell/parse.js"import { ShellSelect } from "../../shell/select.js"import { ShellResult } from "../../shell/result.js"
export const name = "shell"export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_INSTRUCTION = "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response."const OS = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : process.platform === "linux" ? "Linux" : process.platformconst description = (shell?: string) => [ "Execute a shell command and return its output.", ...(shell ? [`Commands run on ${OS} using ${shell}.`] : []), "Quote file paths containing spaces or special characters.", "Prefer dedicated tools over shell commands when possible.", "When output is large, the full result is saved to a file and a truncated preview is returned.", "Rely on automatic truncation unless filtering the output is more useful.", "Commands accept an optional timeout, background commands have no timeout by default.", "Background commands return immediately, and you will be notified when they complete.", ].join(" ")
export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), workdir: Schema.optionalKey(Schema.String).annotate({ description: "Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.", }), timeout: Schema.optionalKey(NonNegativeInt).annotate({ description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`, }), background: Schema.optionalKey(Schema.Boolean).annotate({ description: "Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.", }),})
const StructuredOutput = Schema.Struct({ exit: Schema.optionalKey(Schema.Number), shellID: Schema.optionalKey(Schema.String), truncated: Schema.Boolean, timeout: Schema.optionalKey(Schema.Boolean),})
const Output = Schema.Struct({ ...StructuredOutput.fields, output: Schema.String, status: Schema.optionalKey(Schema.Literals(["completed", "running"])),})
type Output = typeof Output.Type
const resultMessages = (output: Output) => { const notice = output.status === "running" ? BACKGROUND_INSTRUCTION : ShellResult.notice(output) return [output.output, ...(notice ? [notice] : [])]}
const toolResult = (output: Output) => { return { output, content: resultMessages(output).map((text) => ({ type: "text" as const, text })), metadata: { status: output.status, ...ShellResult.metadata(output), ...(output.shellID !== undefined ? { shellID: output.shellID } : {}), }, }}
const backgroundResult = (shellID: string, file: string) => ({ output: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${file}`, shellID, truncated: false, status: "running" as const,})
export const Plugin = { id: "opencode.tool.shell", effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) { const runtime = yield* PluginRuntime.Service const scope = yield* Scope.Scope const environment = yield* Environment.Service const mutation = yield* LocationMutation.Service const shell = yield* Shell.Service const shellSelect = yield* ShellSelect.Service const compatibleShell = shellSelect.resolve({ priority: "compat" }) const permission = yield* Permission.Service const config = yield* Config.Service
const prepare = Effect.fn("ShellTool.prepare")(function* (invocation: ShellCreateBefore, context: Tool.Context) { const source = { type: "tool" as const, messageID: context.messageID, id: context.id, } const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) invocation.cwd = target.absolute const timeout = invocation.timeout const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable }) const directories = yield* Effect.forEach(parsed.directories, (directory) => mutation.resolve({ path: LocationMutation.resolvePath(target.absolute, directory), kind: "directory", }), ) const external = [target, ...directories] .map((item) => item.externalDirectory) .filter((item) => item !== undefined) .filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index) if (external.length > 0) yield* permission.assert({ action: "external_directory", resources: external.map((item) => item.resource), save: external.map((item) => item.save), sessionID: context.sessionID, agent: context.agent, source, }) if (parsed.commands.length > 0) yield* permission.assert({ action: name, resources: parsed.commands.map((command) => command.resource), save: parsed.commands.map((command) => command.save), sessionID: context.sessionID, agent: context.agent, source, }) // Approval can outlive the directory, so validate immediately before spawning. const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe( Effect.catchTag("Environment.NotFound", () => Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)), ), ) if (workdir !== "directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`)) return timeout })
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")( function* ( sessionID: SessionSchema.ID, id: string, shellID: string, command: string, settled: Deferred.Deferred<Output>, ) { const info = (yield* runtime.job.wait({ id })).info if (!info || info.status === "running") return const output = info.status === "completed" ? yield* Deferred.await(settled) : undefined const text = output ? resultMessages(output).join("\n\n") : info.status === "error" ? (info.error ?? "Command failed") : "Command cancelled" yield* runtime.session.synthetic({ ...(info.notificationID ? { id: info.notificationID } : {}), sessionID, description: command, ...ShellResult.notification({ jobID: id, shellID, command, state: info.status, text, output, }), }) if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID) }, Effect.forkIn(scope, { startImmediately: true }), )
yield* ctx.tool .transform((draft) => draft.add({ name, options: { codemode: false }, description: description(), input: Input, output: Output, execute: (input, context) => Effect.gen(function* () { const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS) let finalTimeout = timeout const info = yield* shell.create( { command: input.command, cwd: input.workdir, timeout, shell: yield* compatibleShell, metadata: { sessionID: context.sessionID }, }, (invocation) => Effect.gen(function* () { finalTimeout = yield* prepare(invocation, context) }), ) yield* context.progress({ shellID: info.id })
const settled = yield* Deferred.make<Output>() const run = Effect.gen(function* () { const result = yield* shell.result(info) if (!result.capture) return yield* new Shell.NotFoundError({ id: info.id }) const output = ShellResult.output(result) return { ...output, output: output.timeout ? `${output.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.` : output.output, status: "completed" as const, } }).pipe( Effect.tap((output) => Deferred.succeed(settled, output)), Effect.map((output) => resultMessages(output).join("\n\n")), Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)), ) const job = yield* runtime.job.start({ // CodeMode children share a tool-call ID, but each shell must own its job. id: info.id, type: name, title: info.command, metadata: { sessionID: context.sessionID, shellID: info.id }, recovery: { kind: "shell", sessionID: context.sessionID, shellID: info.id, command: info.command, }, run, })
if (input.background === true) { yield* runtime.job.background(job.id) yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled) return backgroundResult(info.id, info.file) }
const result = yield* runtime.job .block({ id: job.id, sessionID: context.sessionID }) .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) if (result?.type === "backgrounded") { yield* shell.timeout(info.id, 0) yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled) return backgroundResult(info.id, info.file) } if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed")) if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return yield* Deferred.await(settled) }).pipe( Effect.map(toolResult), Effect.mapError( (error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }), ), ), }), ) .pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) => Effect.gen(function* () { const tool = event.tools[name] if (!tool) return tool.description = description(ShellSelect.name(yield* compatibleShell)) }), ) }),}