diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index de62788200b..74f6565e413 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
+import { Output } from "@/util/output"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1485,29 +1486,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
})
- let output = ""
+ const state = Output.create()
- proc.stdout?.on("data", (chunk) => {
- output += chunk.toString()
+ const handleChunk = (chunk: Buffer) => {
+ Output.append(state, chunk)
if (part.state.status === "running") {
part.state.metadata = {
- output: output,
+ output: Output.preview(state),
description: "",
}
Session.updatePart(part)
}
- })
+ }
- proc.stderr?.on("data", (chunk) => {
- output += chunk.toString()
- if (part.state.status === "running") {
- part.state.metadata = {
- output: output,
- description: "",
- }
- Session.updatePart(part)
- }
- })
+ proc.stdout?.on("data", handleChunk)
+ proc.stderr?.on("data", handleChunk)
let aborted = false
let exited = false
@@ -1517,11 +1510,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (abort.aborted) {
aborted = true
await kill()
+ Output.cleanup(state)
}
const abortHandler = () => {
aborted = true
void kill()
+ Output.cleanup(state)
}
abort.addEventListener("abort", abortHandler, { once: true })
@@ -1534,11 +1529,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
})
+ Output.close(state)
+
if (aborted) {
- output += "\n\n" + ["", "User aborted the command", ""].join("\n")
+ Output.appendMetadata(state, "\n\n" + ["", "User aborted the command", ""].join("\n"))
}
+
msg.time.completed = Date.now()
await Session.updateMessage(msg)
+
+ const result = Output.finalize(state)
+
if (part.state.status === "running") {
part.state = {
status: "completed",
@@ -1549,10 +1550,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
input: part.state.input,
title: "",
metadata: {
- output,
+ output: result.preview,
description: "",
+ ...(result.truncated && { outputPath: result.path }),
},
- output,
+ output: result.output,
}
await Session.updatePart(part)
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index bf7c524941f..4b211e36c07 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -9,15 +9,14 @@ import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { $ } from "bun"
-import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
+import { Output } from "@/util/output"
-const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
export const log = Log.create({ service: "bash-tool" })
@@ -164,7 +163,7 @@ export const BashTool = Tool.define("bash", async () => {
detached: process.platform !== "win32",
})
- let output = ""
+ const state = Output.create()
// Initialize metadata with empty output
ctx.metadata({
@@ -175,11 +174,10 @@ export const BashTool = Tool.define("bash", async () => {
})
const append = (chunk: Buffer) => {
- output += chunk.toString()
+ Output.append(state, chunk)
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,
+ output: Output.preview(state),
description: params.description,
},
})
@@ -197,11 +195,13 @@ export const BashTool = Tool.define("bash", async () => {
if (ctx.abort.aborted) {
aborted = true
await kill()
+ Output.cleanup(state)
}
const abortHandler = () => {
aborted = true
void kill()
+ Output.cleanup(state)
}
ctx.abort.addEventListener("abort", abortHandler, { once: true })
@@ -240,18 +240,28 @@ export const BashTool = Tool.define("bash", async () => {
resultMetadata.push("User aborted the command")
}
+ Output.close(state)
+
if (resultMetadata.length > 0) {
- output += "\n\n\n" + resultMetadata.join("\n") + "\n"
+ Output.appendMetadata(state, "\n\n\n" + resultMetadata.join("\n") + "\n")
}
+ const result = Output.finalize(state, {
+ hint: state.file
+ ? `The command output was ${state.written} bytes and was truncated (inline limit: ${Output.THRESHOLD} bytes).\nFull output saved to: ${state.file.path}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+ : undefined,
+ })
+
return {
title: params.description,
metadata: {
- output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
+ output: result.preview,
exit: proc.exitCode,
description: params.description,
+ truncated: result.truncated,
+ outputPath: result.path,
},
- output,
+ output: result.output,
}
},
}
diff --git a/packages/opencode/src/util/output.ts b/packages/opencode/src/util/output.ts
new file mode 100644
index 00000000000..b2e19670bb0
--- /dev/null
+++ b/packages/opencode/src/util/output.ts
@@ -0,0 +1,159 @@
+import fs from "fs"
+import path from "path"
+import { Identifier } from "../id/id"
+import { Truncate } from "../tool/truncation"
+import { Log } from "./log"
+
+const log = Log.create({ service: "output" })
+
+/**
+ * Handles streaming command output with automatic file spillover when threshold is exceeded.
+ * Avoids O(n²) memory usage from repeated string concatenation by:
+ * 1. Accumulating in memory up to a threshold
+ * 2. Streaming to a temp file once threshold is exceeded
+ */
+export namespace Output {
+ export const THRESHOLD = Truncate.MAX_BYTES
+ export const MAX_PREVIEW = 30_000
+
+ export interface StreamFile {
+ fd: number
+ path: string
+ }
+
+ export interface State {
+ /** In-memory buffer (only used when not streaming to file) */
+ buffer: string
+ /** Total bytes received */
+ bytes: number
+ /** Total lines received */
+ lines: number
+ /** File handle when streaming to disk */
+ file?: StreamFile
+ /** Bytes written to file */
+ written: number
+ }
+
+ export interface Result {
+ /** The output content (full if not truncated, preview + hint if truncated) */
+ output: string
+ /** Preview for UI display (always fits in memory) */
+ preview: string
+ /** Whether output was truncated/streamed to file */
+ truncated: boolean
+ /** Path to file if output was streamed */
+ path?: string
+ /** Total bytes of output */
+ bytes: number
+ /** Total lines of output */
+ lines: number
+ }
+
+ export function create(): State {
+ return {
+ buffer: "",
+ bytes: 0,
+ lines: 0,
+ written: 0,
+ }
+ }
+
+ function createFile(state: State): StreamFile | undefined {
+ let fd: number | undefined
+ try {
+ const dir = Truncate.DIR
+ fs.mkdirSync(dir, { recursive: true })
+ Truncate.cleanup().catch(() => {})
+ const filepath = path.join(dir, Identifier.ascending("tool"))
+ fd = fs.openSync(filepath, "w")
+ if (state.buffer) {
+ fs.writeSync(fd, state.buffer)
+ state.written += Buffer.byteLength(state.buffer, "utf-8")
+ }
+ state.buffer = ""
+ return { fd, path: filepath }
+ } catch (e) {
+ if (fd !== undefined) fs.closeSync(fd)
+ log.warn("failed to create stream file, continuing in memory", { error: e })
+ return undefined
+ }
+ }
+
+ export function append(state: State, chunk: Buffer | string): void {
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
+ const size = typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.length
+
+ state.bytes += size
+ state.lines += (text.match(/\n/g) || []).length
+
+ if (!state.file && (state.bytes > THRESHOLD || state.lines > Truncate.MAX_LINES)) {
+ state.file = createFile(state)
+ }
+
+ if (state.file) {
+ fs.writeSync(state.file.fd, text)
+ state.written += Buffer.byteLength(text, "utf-8")
+ } else {
+ state.buffer += text
+ }
+ }
+
+ export function preview(state: State): string {
+ if (state.file) {
+ return `[streaming to file: ${state.written} bytes written...]\n`
+ }
+ if (state.buffer.length > MAX_PREVIEW) {
+ return state.buffer.slice(0, MAX_PREVIEW) + "\n\n..."
+ }
+ return state.buffer
+ }
+
+ export function close(state: State): void {
+ if (state.file) {
+ fs.closeSync(state.file.fd)
+ }
+ }
+
+ export function cleanup(state: State): void {
+ if (state.file) {
+ try {
+ fs.unlinkSync(state.file.path)
+ } catch {}
+ }
+ }
+
+ export function appendMetadata(state: State, metadata: string): void {
+ if (state.file) {
+ fs.appendFileSync(state.file.path, metadata)
+ } else {
+ state.buffer += metadata
+ }
+ }
+
+ export function finalize(state: State, options?: { hint?: string }): Result {
+ const truncated = !!state.file
+ const filepath = state.file?.path
+
+ if (truncated && filepath) {
+ const hint =
+ options?.hint ??
+ `The command output was ${state.written} bytes and was truncated (inline limit: ${THRESHOLD} bytes).\nFull output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+ return {
+ output: hint,
+ preview: `[output streamed to file: ${state.written} bytes]`,
+ truncated: true,
+ path: filepath,
+ bytes: state.bytes,
+ lines: state.lines,
+ }
+ }
+
+ return {
+ output: state.buffer,
+ preview: state.buffer.length > MAX_PREVIEW ? state.buffer.slice(0, MAX_PREVIEW) + "\n\n..." : state.buffer,
+ truncated: false,
+ bytes: state.bytes,
+ lines: state.lines,
+ }
+ }
+}
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index 750ff8193e9..6c4f3ef4abc 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -248,7 +248,7 @@ describe("tool.bash truncation", () => {
)
expect((result.metadata as any).truncated).toBe(true)
expect(result.output).toContain("truncated")
- expect(result.output).toContain("The tool call succeeded but the output was truncated")
+ expect(result.output).toContain("Full output saved to:")
},
})
})
@@ -268,7 +268,7 @@ describe("tool.bash truncation", () => {
)
expect((result.metadata as any).truncated).toBe(true)
expect(result.output).toContain("truncated")
- expect(result.output).toContain("The tool call succeeded but the output was truncated")
+ expect(result.output).toContain("Full output saved to:")
},
})
})