diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index a7b6ec2499a..931983f9b6b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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" @@ -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() + +// 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") @@ -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("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) @@ -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((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((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, } }, }) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 342073b9bb2..00b724a7568 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -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. diff --git a/packages/opencode/src/tool/process-interact.ts b/packages/opencode/src/tool/process-interact.ts new file mode 100644 index 00000000000..0846123339f --- /dev/null +++ b/packages/opencode/src/tool/process-interact.ts @@ -0,0 +1,132 @@ +import { z } from "zod" +import { Tool } from "./tool" +import { runningProcesses } from "./bash" +import { Log } from "../util/log" + +const log = Log.create({ service: "process-interact-tool" }) + +const DESCRIPTION = `Send input or signals to a background process. + +This tool allows interaction with processes running in the background: +- stdin: Send text input to the process +- signal: Send a signal (SIGTERM, SIGKILL, SIGINT, etc.) +- kill: Forcefully terminate the process +` + +interface ProcessInteractMetadata { + success: boolean + message: string + processId: string + pid: number + action: string + data?: string +} + +const interactParameters = z.object({ + processId: z.string().describe("Process ID from bash command"), + action: z.enum(["stdin", "signal", "kill"]).describe("Interaction type"), + data: z.string().optional().describe("Input text for stdin or signal name (e.g., SIGTERM, SIGINT)"), +}) + +export const ProcessInteractTool = Tool.define("process_interact", { + description: DESCRIPTION, + parameters: interactParameters, + + async execute(params, _ctx) { + const proc = runningProcesses.get(params.processId) + if (!proc) { + throw new Error(`Process ${params.processId} not found`) + } + + let success = false + let message = "" + + switch (params.action) { + case "stdin": { + if (!params.data) { + throw new Error("stdin action requires data parameter") + } + + if (proc.process.stdin) { + try { + const writeSuccess = proc.process.stdin.write(params.data + "\n") + if (writeSuccess) { + success = true + message = `Sent ${params.data.length} characters to process stdin` + + // Log the stdin interaction + const inputLog = `[STDIN] ${params.data}\n` + proc.output.push(inputLog) + proc.bufferSize += inputLog.length + proc.metadata.lastOutputTime = Date.now() + } else { + message = "Failed to write to stdin - buffer may be full" + } + } catch (error) { + message = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}` + } + } else { + message = "Process stdin is not available" + } + break + } + + case "signal": { + const signal = params.data || "SIGTERM" + try { + const killed = proc.process.kill(signal as any) + if (killed) { + success = true + message = `Signal ${signal} sent successfully` + log.info("signal sent", { processId: params.processId, signal }) + + // Update status if it's a terminating signal + if (["SIGTERM", "SIGKILL", "SIGINT", "SIGQUIT"].includes(signal)) { + proc.metadata.status = "killed" + } + } else { + message = `Failed to send signal ${signal}` + } + } catch (error) { + message = `Error sending signal: ${error instanceof Error ? error.message : String(error)}` + } + break + } + + case "kill": { + try { + const killed = proc.process.kill("SIGKILL") + if (killed) { + success = true + message = "Process killed successfully" + proc.metadata.status = "killed" + + // Clean up after a delay to ensure process has time to die + setTimeout(() => { + runningProcesses.delete(params.processId) + log.info("process removed from tracking", { processId: params.processId }) + }, 1000) + } else { + message = "Process may have already exited" + } + } catch (error) { + message = `Error killing process: ${error instanceof Error ? error.message : String(error)}` + } + break + } + } + + return { + title: `Process ${params.action}: ${params.processId}`, + metadata: { + success, + message, + processId: params.processId, + pid: proc.metadata.pid, + action: params.action, + data: params.data, + }, + output: message, + } + }, +}) diff --git a/packages/opencode/src/tool/process-interact.txt b/packages/opencode/src/tool/process-interact.txt new file mode 100644 index 00000000000..3e1c79f8269 --- /dev/null +++ b/packages/opencode/src/tool/process-interact.txt @@ -0,0 +1,28 @@ +Send input or signals to a background process. + +This tool allows you to interact with processes that are running in the background after being started by the bash tool: + +Actions: +- stdin: Send text input to the process (adds newline automatically) +- signal: Send a Unix signal to the process (e.g., SIGTERM, SIGINT, SIGHUP) +- kill: Forcefully terminate the process with SIGKILL + +Parameters: +- processId: The ID returned when a bash command was backgrounded +- action: The type of interaction (stdin, signal, or kill) +- data: For stdin - the text to send; For signal - the signal name (default: SIGTERM) + +Common signals: +- SIGTERM: Graceful termination request (default) +- SIGINT: Interrupt signal (like Ctrl+C) +- SIGKILL: Force kill (cannot be caught) +- SIGHUP: Hangup signal +- SIGUSR1/SIGUSR2: User-defined signals + +Usage examples: +- Send input: process_interact processId="abc-123" action="stdin" data="help" +- Send Ctrl+C: process_interact processId="abc-123" action="signal" data="SIGINT" +- Graceful stop: process_interact processId="abc-123" action="signal" +- Force kill: process_interact processId="abc-123" action="kill" + +Note: stdin input is logged in the process output buffer prefixed with [STDIN]. \ No newline at end of file diff --git a/packages/opencode/src/tool/process-list.ts b/packages/opencode/src/tool/process-list.ts new file mode 100644 index 00000000000..04f6475a6e1 --- /dev/null +++ b/packages/opencode/src/tool/process-list.ts @@ -0,0 +1,75 @@ +import { z } from "zod" +import { Tool } from "./tool" +import { runningProcesses } from "./bash" + +const DESCRIPTION = `List all running background processes started by bash commands. + +This tool shows all processes that are currently running in the background after being started by the bash tool. +It includes information about their runtime, buffer size, and status. +` + +interface ProcessListMetadata { + processes: Array<{ + id: string + pid: number + command: string + running: boolean + runtime: number + bufferSize: number + bufferSizeMB: string + bufferStatus: "OK" | "WARNING" + unreadBytes: number + warningCount: number + }> + globalStats: { + totalBufferSize: number + processCount: number + } +} + +export const ProcessListTool = Tool.define, ProcessListMetadata>("process_list", { + description: DESCRIPTION, + parameters: z.object({}), + + async execute(_params, _ctx) { + const processes = [] + + for (const [id, proc] of runningProcesses.entries()) { + processes.push({ + id, + pid: proc.metadata.pid, + command: proc.metadata.command, + running: proc.metadata.status === "running", + runtime: Date.now() - proc.metadata.startTime, + bufferSize: proc.bufferSize, + bufferSizeMB: (proc.bufferSize / 1024 / 1024).toFixed(2), + bufferStatus: (proc.bufferSize > 100 * 1024 * 1024 ? "WARNING" : "OK") as "OK" | "WARNING", + unreadBytes: proc.output.slice(proc.readCursor).join("").length, + warningCount: proc.metadata.bufferWarnings, + }) + } + + const globalStats = { + totalBufferSize: Array.from(runningProcesses.values()).reduce((sum, p) => sum + p.bufferSize, 0), + processCount: runningProcesses.size, + } + + return { + title: "Background process list", + metadata: { + processes, + globalStats, + }, + output: + processes.length === 0 + ? "No background processes running" + : processes + .map( + (p) => + `${p.running ? "🟢" : "⚫"} [${p.id}] PID:${p.pid} - ${p.command.slice(0, 50)}${p.command.length > 50 ? "..." : ""}\n` + + ` Runtime: ${Math.floor(p.runtime / 1000)}s | Buffer: ${p.bufferSizeMB}MB (${p.bufferStatus})`, + ) + .join("\n\n"), + } + }, +}) diff --git a/packages/opencode/src/tool/process-list.txt b/packages/opencode/src/tool/process-list.txt new file mode 100644 index 00000000000..8d394f81b7a --- /dev/null +++ b/packages/opencode/src/tool/process-list.txt @@ -0,0 +1,29 @@ +List all running background processes started by bash commands. + +This tool shows all processes that are currently running in the background after being started by the bash tool. It provides an overview of their status, runtime, and buffer usage. + +Information provided for each process: +- Process ID: Unique identifier for interacting with the process +- PID: System process ID +- Command: The command that was executed (truncated if long) +- Status: Running (🟢) or Stopped (⚫) +- Runtime: How long the process has been running +- Buffer Size: Current memory usage for output storage +- Buffer Status: OK or WARNING (if over 100MB) +- Unread Bytes: Amount of output not yet read +- Warning Count: Number of buffer warnings issued + +Global statistics: +- Total buffer size across all processes +- Number of tracked processes + +No parameters required. + +Usage: +process_list + +This tool is useful for: +- Checking which processes are still running +- Monitoring memory usage of background processes +- Finding process IDs for interaction with other process tools +- Identifying processes that may need buffer trimming \ No newline at end of file diff --git a/packages/opencode/src/tool/process-stream.ts b/packages/opencode/src/tool/process-stream.ts new file mode 100644 index 00000000000..f48f9021be1 --- /dev/null +++ b/packages/opencode/src/tool/process-stream.ts @@ -0,0 +1,107 @@ +import { z } from "zod" +import { Tool } from "./tool" +import { runningProcesses } from "./bash" + +const DESCRIPTION = `Read output from a background process with stream-like functionality. + +This tool provides different ways to read output from processes running in the background: +- read: Consume and return unread output (default) +- peek: View unread output without consuming it +- tail: Get the most recent output regardless of read position +- reset: Reset the read cursor to the beginning +` + +interface ProcessStreamMetadata { + output: string + consumed: boolean + processId: string + pid: number + bytesRead: number + unreadBytes: number + totalBytes: number + bufferStatus: "OK" | "WARNING" +} + +const streamParameters = z.object({ + processId: z.string().describe("Process ID from bash command"), + action: z.enum(["read", "peek", "tail", "reset"]).describe("Stream action to perform").default("read"), + maxBytes: z.number().optional().describe("Maximum bytes to read (default: all available)"), +}) + +export const ProcessStreamTool = Tool.define("process_stream", { + description: DESCRIPTION, + parameters: streamParameters, + + async execute(params, _ctx) { + const proc = runningProcesses.get(params.processId) + if (!proc) { + throw new Error(`Process ${params.processId} not found`) + } + + let output = "" + let consumed = false + let bytesRead = 0 + + switch (params.action) { + case "read": { + // Consume and return unread data + const unread = proc.output.slice(proc.readCursor) + const unreadText = unread.join("") + output = params.maxBytes ? unreadText.slice(0, params.maxBytes) : unreadText + bytesRead = output.length + + // Update cursor to mark as read + const newChunksRead = unread.slice(0, Math.ceil((bytesRead / unreadText.length) * unread.length)) + proc.readCursor += newChunksRead.length + consumed = true + break + } + + case "peek": { + // Return unread data without consuming + const unreadText = proc.output.slice(proc.readCursor).join("") + output = params.maxBytes ? unreadText.slice(0, params.maxBytes) : unreadText + bytesRead = output.length + consumed = false + break + } + + case "tail": { + // Get last N bytes regardless of read cursor + const allText = proc.output.join("") + const tailBytes = params.maxBytes || 10000 + output = allText.length > tailBytes ? allText.slice(-tailBytes) : allText + bytesRead = output.length + consumed = false + break + } + + case "reset": { + // Reset read cursor to beginning + proc.readCursor = 0 + output = "Read cursor reset to beginning" + consumed = false + bytesRead = 0 + break + } + } + + const unreadBytes = proc.output.slice(proc.readCursor).join("").length + const bufferStatus = proc.bufferSize > 100 * 1024 * 1024 ? "WARNING" : "OK" + + return { + title: `Process stream: ${params.action}`, + metadata: { + output, + consumed, + processId: params.processId, + pid: proc.metadata.pid, + bytesRead, + unreadBytes, + totalBytes: proc.bufferSize, + bufferStatus, + }, + output: params.action === "reset" ? output : output || "(no output)", + } + }, +}) diff --git a/packages/opencode/src/tool/process-stream.txt b/packages/opencode/src/tool/process-stream.txt new file mode 100644 index 00000000000..7860434cd7d --- /dev/null +++ b/packages/opencode/src/tool/process-stream.txt @@ -0,0 +1,22 @@ +Read output from a background process with stream-like functionality. + +This tool provides different ways to read output from processes that were started by the bash tool and are now running in the background: + +Actions: +- read: Consume and return unread output (default) - marks output as read +- peek: View unread output without consuming it - does not update read position +- tail: Get the most recent output regardless of read position +- reset: Reset the read cursor to the beginning + +Parameters: +- processId: The ID returned when a bash command was backgrounded +- action: The stream operation to perform (default: "read") +- maxBytes: Optional limit on bytes to return + +Usage examples: +- Read new output: process_stream processId="abc-123" +- Peek without consuming: process_stream processId="abc-123" action="peek" +- Get last 1KB: process_stream processId="abc-123" action="tail" maxBytes=1024 +- Reset cursor: process_stream processId="abc-123" action="reset" + +The tool tracks what output has been read to avoid duplicates when using the "read" action. \ No newline at end of file diff --git a/packages/opencode/src/tool/process-trim.ts b/packages/opencode/src/tool/process-trim.ts new file mode 100644 index 00000000000..5d35987b7a6 --- /dev/null +++ b/packages/opencode/src/tool/process-trim.ts @@ -0,0 +1,82 @@ +import { z } from "zod" +import { Tool } from "./tool" +import { runningProcesses, trimBuffer } from "./bash" + +const DESCRIPTION = `Trim the output buffer of a background process to save memory. + +This tool helps manage memory usage by trimming process output buffers: +- Retain a specified amount of recent output (default 1MB, max 100MB) +- Choose to keep either the most recent output or unread output +- Automatically resets read cursor when trimming +` + +interface ProcessTrimMetadata { + success: boolean + processId: string + pid: number + beforeSize: number + afterSize: number + freedBytes: number + freedMB: number + retainMode: "recent" | "unread" +} + +const BUFFER_SOFT_LIMIT = 100 * 1024 * 1024 // 100MB +const DEFAULT_TRIM_SIZE = 1 * 1024 * 1024 // 1MB + +const trimParameters = z.object({ + processId: z.string().describe("Process ID to trim"), + retainSize: z.number().optional().describe("Bytes to retain (max 100MB, default 1MB)"), + retainMode: z.enum(["recent", "unread"]).optional().describe("What to keep: 'recent' (default) or 'unread' output"), +}) + +export const ProcessTrimTool = Tool.define("process_trim", { + description: DESCRIPTION, + parameters: trimParameters, + + async execute(params, _ctx) { + const proc = runningProcesses.get(params.processId) + if (!proc) { + throw new Error(`Process ${params.processId} not found`) + } + + const retainSize = Math.min(params.retainSize || DEFAULT_TRIM_SIZE, BUFFER_SOFT_LIMIT) + const mode = params.retainMode || "recent" + const beforeSize = proc.bufferSize + + if (mode === "recent") { + // Use the shared trimBuffer function for recent mode + trimBuffer(params.processId, retainSize) + } else if (mode === "unread") { + // Keep unread portion up to limit + const unreadOutput = proc.output.slice(proc.readCursor).join("") + if (unreadOutput.length > retainSize) { + // Keep only the most recent part of unread output + proc.output = [unreadOutput.slice(-retainSize)] + } else { + // Keep all unread output + proc.output = [unreadOutput] + } + proc.bufferSize = proc.output[0].length + proc.readCursor = 0 + } + + const afterSize = proc.bufferSize + const freedBytes = beforeSize - afterSize + + return { + title: `Trimmed process buffer: ${params.processId}`, + metadata: { + success: true, + processId: params.processId, + pid: proc.metadata.pid, + beforeSize, + afterSize, + freedBytes, + freedMB: Math.round((freedBytes / 1024 / 1024) * 100) / 100, + retainMode: mode, + }, + output: `Buffer trimmed from ${(beforeSize / 1024 / 1024).toFixed(2)}MB to ${(afterSize / 1024 / 1024).toFixed(2)}MB (freed ${(freedBytes / 1024 / 1024).toFixed(2)}MB)`, + } + }, +}) diff --git a/packages/opencode/src/tool/process-trim.txt b/packages/opencode/src/tool/process-trim.txt new file mode 100644 index 00000000000..0feaba01e52 --- /dev/null +++ b/packages/opencode/src/tool/process-trim.txt @@ -0,0 +1,24 @@ +Trim the output buffer of a background process to save memory. + +This tool helps manage memory usage by reducing the size of output buffers for long-running background processes. It's especially useful when you receive buffer warnings from the bash tool. + +Parameters: +- processId: The ID of the process whose buffer to trim +- retainSize: Bytes to retain (default: 1MB, maximum: 100MB) +- retainMode: What to keep - "recent" (default) or "unread" output + +Modes: +- recent: Keeps the most recent N bytes of output, regardless of read status +- unread: Keeps only unread output up to the specified size + +Behavior: +- Automatically resets the read cursor when trimming +- Cannot retain more than 100MB (soft limit) +- Frees memory by discarding older output + +Usage examples: +- Keep last 1MB: process_trim processId="abc-123" +- Keep last 5MB: process_trim processId="abc-123" retainSize=5242880 +- Keep unread only: process_trim processId="abc-123" retainMode="unread" + +Note: This tool is automatically suggested when a process buffer exceeds 100MB. Buffers exceeding 200MB are automatically trimmed to 1MB. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c25b16ed3a4..a326e3f25e0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,6 +11,10 @@ import { TodoWriteTool, TodoReadTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" +import { ProcessListTool } from "./process-list" +import { ProcessStreamTool } from "./process-stream" +import { ProcessInteractTool } from "./process-interact" +import { ProcessTrimTool } from "./process-trim" import type { Agent } from "../agent/agent" export namespace ToolRegistry { @@ -28,6 +32,10 @@ export namespace ToolRegistry { TodoWriteTool, TodoReadTool, TaskTool, + ProcessListTool, + ProcessStreamTool, + ProcessInteractTool, + ProcessTrimTool, ] export function ids() { diff --git a/packages/opencode/test/process-test.ts b/packages/opencode/test/process-test.ts new file mode 100644 index 00000000000..47f5ac1cc59 --- /dev/null +++ b/packages/opencode/test/process-test.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun + +// Simple test to verify background process handling works + +import { runningProcesses } from "../src/tool/bash" + +async function test() { + console.log("Testing background process handling...") + + // Mock a simple long-running process + const { exec } = await import("child_process") + const { randomUUID } = await import("crypto") + + const processId = randomUUID() + const command = 'while true; do echo "Test output $(date)"; sleep 1; done' + + console.log("Starting test process...") + const childProcess = exec(command, { shell: "/bin/bash" }) + + if (!childProcess.pid) { + console.error("Failed to start process") + process.exit(1) + } + + // Add to running processes + runningProcesses.set(processId, { + process: childProcess, + id: processId, + output: [], + bufferSize: 0, + readCursor: 0, + metadata: { + command, + startTime: Date.now(), + lastOutputTime: Date.now(), + pid: childProcess.pid, + bufferWarnings: 0, + status: "running", + }, + }) + + // Capture output + childProcess.stdout?.on("data", (chunk) => { + const proc = runningProcesses.get(processId) + if (proc) { + const text = chunk.toString() + proc.output.push(text) + proc.bufferSize += text.length + proc.metadata.lastOutputTime = Date.now() + console.log("Output:", text.trim()) + } + }) + + console.log(`Process started with ID: ${processId}, PID: ${childProcess.pid}`) + console.log("Running processes:", runningProcesses.size) + + // Wait 3 seconds then kill + setTimeout(() => { + console.log("\nKilling process...") + childProcess.kill() + runningProcesses.delete(processId) + console.log("Process killed. Running processes:", runningProcesses.size) + process.exit(0) + }, 3000) +} + +test()