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
298 changes: 263 additions & 35 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod"
import { exec } from "child_process"
import { exec, type ChildProcess } from "child_process"
import { randomUUID } from "crypto"

import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
Expand All @@ -15,9 +16,88 @@ import { Agent } from "../agent/agent"
const MAX_OUTPUT_LENGTH = 30_000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
const DEFAULT_OUTPUT_TIMEOUT = 3 * 1000
const BUFFER_SOFT_LIMIT = 100 * 1024 * 1024 // 100MB
const BUFFER_HARD_LIMIT = 200 * 1024 * 1024 // 200MB
const DEFAULT_TRIM_SIZE = 1 * 1024 * 1024 // 1MB

const log = Log.create({ service: "bash-tool" })

// Process tracking
export interface RunningProcess {
process: ChildProcess
id: string
output: string[]
bufferSize: number
readCursor: number
metadata: {
command: string
startTime: number
lastOutputTime: number
pid: number
bufferWarnings: number
status: "running" | "completed" | "killed"
}
}

export interface ProcessBufferWarning {
processId: string
pid: number
command: string
bufferSize: number
bufferSizeMB: number
exceededBy: number
message: string
autoTrimmed?: boolean
}

// Global process tracking
export const runningProcesses = new Map<string, RunningProcess>()

// Helper functions
function checkBufferWarnings(): ProcessBufferWarning[] {
const warnings: ProcessBufferWarning[] = []

for (const [id, proc] of runningProcesses.entries()) {
if (proc.bufferSize > BUFFER_SOFT_LIMIT) {
warnings.push({
processId: id,
pid: proc.metadata.pid,
command: proc.metadata.command.slice(0, 50) + (proc.metadata.command.length > 50 ? "..." : ""),
bufferSize: proc.bufferSize,
bufferSizeMB: Math.round(proc.bufferSize / 1024 / 1024),
exceededBy: proc.bufferSize - BUFFER_SOFT_LIMIT,
message: `Process ${id} buffer exceeds 100MB. Use process_trim to reduce.`,
autoTrimmed: proc.bufferSize > BUFFER_HARD_LIMIT,
})

proc.metadata.bufferWarnings++

// Auto-trim if exceeds hard limit
if (proc.bufferSize > BUFFER_HARD_LIMIT) {
trimBuffer(id, DEFAULT_TRIM_SIZE)
}
}
}

return warnings
}

export function trimBuffer(processId: string, retainSize: number): void {
const proc = runningProcesses.get(processId)
if (!proc) return

const fullOutput = proc.output.join("")
if (fullOutput.length > retainSize) {
const keepFrom = fullOutput.length - retainSize
const retained = fullOutput.slice(keepFrom)
proc.output = [retained]
proc.bufferSize = retained.length
proc.readCursor = 0 // Reset cursor since we trimmed
log.info("auto-trimmed buffer", { processId, newSize: proc.bufferSize })
}
}

const parser = lazy(async () => {
try {
const { default: Parser } = await import("tree-sitter")
Expand All @@ -43,19 +123,37 @@ const parser = lazy(async () => {
}
})

export const BashTool = Tool.define("bash", {
const bashParameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
outputTimeout: z
.number()
.describe("Timeout in milliseconds to wait for output before backgrounding process")
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})

interface BashMetadata {
output: string
exit?: number | null
status?: "background" | "completed"
processId?: string
pid?: number
message?: string
description: string
bufferWarnings?: ProcessBufferWarning[]
}

export const BashTool = Tool.define<typeof bashParameters, BashMetadata>("bash", {
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
parameters: bashParameters,
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
const outputTimeout = params.outputTimeout ?? DEFAULT_OUTPUT_TIMEOUT
const tree = await parser().then((p) => p.parse(params.command))
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)

Expand Down Expand Up @@ -121,69 +219,199 @@ export const BashTool = Tool.define("bash", {
})
}

// Check buffer warnings before executing
const bufferWarnings = checkBufferWarnings()

let hasReturned = false
let outputBuffer = ""
let lastOutputTime = Date.now()
const processId = randomUUID()

// Start the process
const process = exec(params.command, {
cwd: Instance.directory,
signal: ctx.abort,
timeout,
// Don't use timeout option here - we handle it ourselves
})

let output = ""
if (!process.pid) {
throw new Error("Failed to start process")
}

// Initialize metadata with empty output
ctx.metadata({
metadata: {
output: "",
description: params.description,
bufferWarnings: bufferWarnings.length > 0 ? bufferWarnings : undefined,
},
})

// Set up output handlers
process.stdout?.on("data", (chunk) => {
output += chunk.toString()
ctx.metadata({
metadata: {
output: output,
description: params.description,
},
})
lastOutputTime = Date.now()
const text = chunk.toString()
outputBuffer += text

// Update tracked process if it exists
const tracked = runningProcesses.get(processId)
if (tracked) {
tracked.output.push(text)
tracked.bufferSize += text.length
tracked.metadata.lastOutputTime = lastOutputTime
}

// Update live metadata if not yet returned
if (!hasReturned) {
ctx.metadata({
metadata: {
output: outputBuffer,
description: params.description,
bufferWarnings: bufferWarnings.length > 0 ? bufferWarnings : undefined,
},
})
}
})

process.stderr?.on("data", (chunk) => {
output += chunk.toString()
ctx.metadata({
lastOutputTime = Date.now()
const text = chunk.toString()
outputBuffer += text

// Update tracked process if it exists
const tracked = runningProcesses.get(processId)
if (tracked) {
tracked.output.push(text)
tracked.bufferSize += text.length
tracked.metadata.lastOutputTime = lastOutputTime
}

// Update live metadata if not yet returned
if (!hasReturned) {
ctx.metadata({
metadata: {
output: outputBuffer,
description: params.description,
bufferWarnings: bufferWarnings.length > 0 ? bufferWarnings : undefined,
},
})
}
})

// Create promise for process completion
const processComplete = new Promise<number | null>((resolve) => {
process.on("close", (code) => {
resolve(code)
})
})

// Create promise for output timeout
const timeoutPromise = new Promise<"timeout">((resolve) => {
const checkInterval = setInterval(() => {
if (Date.now() - lastOutputTime > outputTimeout && !hasReturned) {
clearInterval(checkInterval)
resolve("timeout")
}
}, 500)

// Clean up interval if process completes
processComplete.then(() => clearInterval(checkInterval))
})

// Create promise for hard timeout
const hardTimeoutPromise = new Promise<"hard-timeout">((resolve) => {
setTimeout(() => {
if (!hasReturned) {
resolve("hard-timeout")
}
}, timeout)
})

// Race between completion, output timeout, and hard timeout
const result = await Promise.race([processComplete, timeoutPromise, hardTimeoutPromise])

if ((result === "timeout" || result === "hard-timeout") && !hasReturned) {
hasReturned = true

// Track the process for future interaction
runningProcesses.set(processId, {
process,
id: processId,
output: outputBuffer ? [outputBuffer] : [],
bufferSize: outputBuffer.length,
readCursor: 0,
metadata: {
output: output,
description: params.description,
command: params.command,
startTime: Date.now(),
lastOutputTime,
pid: process.pid!,
bufferWarnings: 0,
status: "running",
},
})
})

await new Promise<void>((resolve) => {
process.on("close", () => {
resolve()
// Continue capturing output in background
process.on("close", (code) => {
const tracked = runningProcesses.get(processId)
if (tracked) {
tracked.metadata.status = "completed"
log.info("background process completed", { processId, code })
}
})
})

// Truncate output if needed
if (outputBuffer.length > MAX_OUTPUT_LENGTH) {
outputBuffer = outputBuffer.slice(0, MAX_OUTPUT_LENGTH) + "\n\n(Output was truncated due to length limit)"
}

const timeoutType = result === "timeout" ? "output timeout" : "execution timeout"

// Return early with special metadata
return {
title: params.command,
metadata: {
output: outputBuffer,
status: "background",
processId,
pid: process.pid,
message: `Process continues running in background (${timeoutType}). Use process_list to check status.`,
description: params.description,
bufferWarnings: bufferWarnings.length > 0 ? bufferWarnings : undefined,
},
output:
outputBuffer ||
`Process started in background (${timeoutType}). Process ID: ${processId}, PID: ${process.pid}`,
}
}

// Normal completion
hasReturned = true

// Update metadata with final status
ctx.metadata({
metadata: {
output: output,
output: outputBuffer,
exit: process.exitCode,
description: params.description,
bufferWarnings: bufferWarnings.length > 0 ? bufferWarnings : undefined,
},
})

if (output.length > MAX_OUTPUT_LENGTH) {
output = output.slice(0, MAX_OUTPUT_LENGTH)
output += "\n\n(Output was truncated due to length limit)"
// Truncate output if needed
if (outputBuffer.length > MAX_OUTPUT_LENGTH) {
outputBuffer = outputBuffer.slice(0, MAX_OUTPUT_LENGTH) + "\n\n(Output was truncated due to length limit)"
}

return {
title: params.command,
metadata: {
output,
output: outputBuffer,
exit: process.exitCode,
status: "completed",
description: params.description,
bufferWarnings: bufferWarnings.length > 0 ? bufferWarnings : undefined,
},
output,
output: outputBuffer,
}
},
})
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/bash.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- You can specify an optional outputTimeout in milliseconds to detect non-interactive processes. If no output is received within this time, the process will be backgrounded and continue running. Default is 3000ms (3 seconds).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
- Background processes: If a process produces no output for outputTimeout ms, it will continue running in the background. Use process_list to see running processes, process_stream to read output, process_interact to send input/signals, and process_trim to manage memory.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
Expand Down
Loading