export * as ShellTool from "./shell.js" import { ToolFailure } from "@opencode/ai" import type { Context } from "@opencode/plugin/effect/plugin" import type { SessionHooks } from "@opencode/plugin/effect/session" import type { ShellCreateBefore } from "@opencode/plugin/effect/shell" import type { Tool } from "@opencode/schema/tool" import { Deferred, Effect, Schema, Scope } from "effect" import { Config } from "../../config.js" import { Environment } from "../../environment/index.js" import { Job } from "../../job.js" import { FileAccess } from "../../file-access.js" import { Permission } from "../../permission.js" import { NonNegativeInt } from "../../schema.js" import { Session } from "../../session.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. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes." const OS = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : process.platform === "linux" ? "Linux" : process.platform const 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 (useful for dev servers and long-running builds). You do not need to use '&' at the end of the command when using this parameter. You will be notified when it completes. DO NOT poll for completion.", }), }) const StructuredOutput = Schema.Struct({ exit: Schema.optionalKey(Schema.Number), signal: Schema.optionalKey(Schema.String), 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 ? [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 sessions = yield* Session.Service const jobs = yield* Job.Service const scope = yield* Scope.Scope const environment = yield* Environment.Service const access = yield* FileAccess.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* access.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) => access.resolve({ path: FileAccess.resolvePath(target.absolute, directory), kind: "directory", }), ) yield* access.authorizeExternal([target, ...directories], context) 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, ) { const info = (yield* jobs.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") : "Cancelled" yield* sessions.synthetic({ ...(info.notificationID ? { id: info.notificationID } : {}), sessionID, description: command, ...ShellResult.notification({ jobID: id, shellID, command, state: info.status, text, output, }), }) if (info.notificationID) yield* jobs.completeBackground(info.notificationID) }, Effect.forkIn(scope, { startImmediately: true }), ) yield* ctx.tool .transform((editor) => editor.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* () { invocation.env.AGENT = "1" invocation.env.OPENCODE = "1" invocation.env.AI_AGENT ||= "opencode" invocation.env.OPENCODE_SESSION_ID = context.sessionID finalTimeout = yield* prepare(invocation, context) }), ) yield* context.progress({ shellID: info.id }) const settled = yield* Deferred.make() 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* jobs.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* jobs.background(job.id) yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled) return backgroundResult(info.id, info.file) } const result = yield* jobs .block({ id: job.id, sessionID: context.sessionID }) .pipe(Effect.onInterrupt(() => jobs.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) const hook = (event: SessionHooks["context"]) => Effect.gen(function* () { const tool = event.tools[name] if (!tool) return tool.description = description(ShellSelect.name(yield* compatibleShell)) }) yield* ctx.session.hook("context", hook) yield* ctx.session.hook("compaction", hook) yield* ctx.session.hook("generate", hook) }), }