diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f891612272c..f399ff89eec 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -29,7 +29,6 @@ import { ListTool } from "../tool/ls" import { FileTime } from "../file/time" import { Flag } from "../flag/flag" import { ulid } from "ulid" -import { spawn } from "child_process" import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" @@ -1290,121 +1289,29 @@ export namespace SessionPrompt { }, } await Session.updatePart(part) - const shell = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) - ).toLowerCase() - - const invocations: Record = { - nu: { - args: ["-c", input.command], - }, - fish: { - args: ["-c", input.command], - }, - zsh: { - args: [ - "-c", - "-l", - ` - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} - `, - ], - }, - bash: { - args: [ - "-c", - "-l", - ` - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} - `, - ], - }, - // Windows cmd - cmd: { - args: ["/c", input.command], - }, - // Windows PowerShell - powershell: { - args: ["-NoProfile", "-Command", input.command], - }, - pwsh: { - args: ["-NoProfile", "-Command", input.command], - }, - // Fallback: any shell that doesn't match those above - // - No -l, for max compatibility - "": { - args: ["-c", `${input.command}`], - }, - } - const matchingInvocation = invocations[shellName] ?? invocations[""] - const args = matchingInvocation?.args + let currentOutput = "" - const proc = spawn(shell, args, { + const result = await Shell.execute({ + command: input.command, cwd: Instance.directory, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - TERM: "dumb", - }, - }) - - let output = "" - - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) - - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", + shell: Shell.preferred(), + loadRcFiles: true, + abort, + onOutput: (output) => { + currentOutput = output + if (part.state.status === "running") { + part.state.metadata = { + output, + description: "", + } + Session.updatePart(part) } - Session.updatePart(part) - } - }) - - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (abort.aborted) { - aborted = true - await kill() - } - - const abortHandler = () => { - aborted = true - void kill() - } - - abort.addEventListener("abort", abortHandler, { once: true }) - - await new Promise((resolve) => { - proc.on("close", () => { - exited = true - abort.removeEventListener("abort", abortHandler) - resolve() - }) + }, }) - if (aborted) { + let output = currentOutput + if (result.aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } msg.time.completed = Date.now() diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 2e8d48bfd92..c15c1548e03 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -64,4 +64,132 @@ export namespace Shell { if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s return fallback() }) + + function getInvocationArgs(shell: string, command: string): string[] { + const shellName = ( + process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) + ).toLowerCase() + + const invocations: Record = { + nu: ["-c", command], + fish: ["-c", command], + zsh: [ + "-c", + "-l", + `[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true +[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true +eval ${JSON.stringify(command)}`, + ], + bash: [ + "-c", + "-l", + `shopt -s expand_aliases +[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true +eval ${JSON.stringify(command)}`, + ], + cmd: ["/c", command], + powershell: ["-NoProfile", "-Command", command], + pwsh: ["-NoProfile", "-Command", command], + } + + return invocations[shellName] ?? ["-c", command] + } + + // ============ Unified Execution ============ + + export interface ExecuteOptions { + command: string + cwd: string + shell?: string + loadRcFiles?: boolean + timeout?: number + abort: AbortSignal + env?: Record + onOutput?: (output: string) => void + } + + export interface ExecuteResult { + output: string + exitCode: number | null + timedOut: boolean + aborted: boolean + } + + export async function execute(options: ExecuteOptions): Promise { + const { command, cwd, shell = acceptable(), loadRcFiles = false, timeout, abort, env = {}, onOutput } = options + + const proc = loadRcFiles + ? spawn(shell, getInvocationArgs(shell, command), { + cwd, + env: { ...process.env, TERM: "dumb", ...env }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + : spawn(command, { + shell, + cwd, + env: { ...process.env, TERM: "dumb", ...env }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + let output = "" + let timedOut = false + let aborted = false + let exited = false + + const append = (chunk: Buffer) => { + output += chunk.toString() + onOutput?.(output) + } + + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) + + const kill = () => killTree(proc, { exited: () => exited }) + + if (abort.aborted) { + aborted = true + await kill() + } + + const abortHandler = () => { + aborted = true + void kill() + } + abort.addEventListener("abort", abortHandler, { once: true }) + + const timeoutTimer = timeout + ? setTimeout(() => { + timedOut = true + void kill() + }, timeout + 100) + : undefined + + await new Promise((resolve, reject) => { + const cleanup = () => { + if (timeoutTimer) clearTimeout(timeoutTimer) + abort.removeEventListener("abort", abortHandler) + } + + proc.once("exit", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + }) + + return { + output, + exitCode: proc.exitCode, + timedOut, + aborted, + } + } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e06a3f157cb..35b8589b20b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,5 +1,4 @@ import z from "zod" -import { spawn } from "child_process" import { Tool } from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" @@ -18,6 +17,7 @@ import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" const MAX_METADATA_LENGTH = 30_000 +const MAX_OUTPUT_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) @@ -52,8 +52,7 @@ const parser = lazy(async () => { // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { - const shell = Shell.acceptable() - log.info("bash tool using shell", { shell }) + log.info("bash tool using shell", { shell: Shell.acceptable() }) return { description: DESCRIPTION.replaceAll("${directory}", Instance.directory) @@ -154,18 +153,6 @@ export const BashTool = Tool.define("bash", async () => { }) } - const proc = spawn(params.command, { - shell, - cwd, - env: { - ...process.env, - }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }) - - let output = "" - // Initialize metadata with empty output ctx.metadata({ metadata: { @@ -174,69 +161,40 @@ export const BashTool = Tool.define("bash", async () => { }, }) - const append = (chunk: Buffer) => { - output += chunk.toString() - ctx.metadata({ - metadata: { - // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - description: params.description, - }, - }) - } - - proc.stdout?.on("data", append) - proc.stderr?.on("data", append) - - let timedOut = false - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (ctx.abort.aborted) { - aborted = true - await kill() - } - - const abortHandler = () => { - aborted = true - void kill() - } - - ctx.abort.addEventListener("abort", abortHandler, { once: true }) - - const timeoutTimer = setTimeout(() => { - timedOut = true - void kill() - }, timeout + 100) - - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeoutTimer) - ctx.abort.removeEventListener("abort", abortHandler) - } - - proc.once("exit", () => { - exited = true - cleanup() - resolve() - }) + let currentOutput = "" - proc.once("error", (error) => { - exited = true - cleanup() - reject(error) - }) + const result = await Shell.execute({ + command: params.command, + cwd, + loadRcFiles: true, + timeout, + abort: ctx.abort, + onOutput: (output) => { + currentOutput = output + if (output.length <= MAX_OUTPUT_LENGTH) { + ctx.metadata({ + metadata: { + output, + description: params.description, + }, + }) + } + }, }) + let output = currentOutput const resultMetadata: string[] = [] - if (timedOut) { + if (output.length > MAX_OUTPUT_LENGTH) { + output = output.slice(0, MAX_OUTPUT_LENGTH) + resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`) + } + + if (result.timedOut) { resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } - if (aborted) { + if (result.aborted) { resultMetadata.push("User aborted the command") } @@ -248,7 +206,7 @@ export const BashTool = Tool.define("bash", async () => { title: params.description, metadata: { output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - exit: proc.exitCode, + exit: result.exitCode, description: params.description, }, output,