mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
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<Output>,
|
|
) {
|
|
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<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* 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)
|
|
}),
|
|
}
|