From 3881055567825c0d6236f89eae06801cdeda063a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 29 Mar 2026 10:53:32 +0000 Subject: [PATCH 1/6] fix: unarchive session on touch, stop cache eviction on archive --- packages/app/src/context/global-sync/event-reducer.ts | 1 - packages/opencode/src/session/index.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d8..91fe86f31ba 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -128,7 +128,6 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index eb01739c156..0eb442517b0 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -561,7 +561,7 @@ export namespace Session { Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info })) const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() } }) + yield* patch(sessionID, { time: { updated: Date.now(), archived: null } }) }) const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { From 9c35331e3216cdceec47419faf97d5479853577d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 29 Mar 2026 10:54:11 +0000 Subject: [PATCH 2/6] perf(bash): spool large command output to disk instead of accumulating in memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, bash tool and /shell command accumulated ALL stdout/stderr in an unbounded string — a verbose command could grow to hundreds of MB. Now output beyond Truncate.MAX_BYTES (50KB) is streamed directly to a spool file on disk. Only the first 50KB is kept in memory for the metadata preview. The full output remains recoverable via the spool file path included in the tool result metadata. --- packages/opencode/src/tool/bash.ts | 71 +++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 228c2161b9d..4fd5d96d29e 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -2,12 +2,12 @@ import z from "zod" import { spawn } from "child_process" import { Tool } from "./tool" import path from "path" +import fs from "fs" import DESCRIPTION from "./bash.txt" import { Log } from "../util/log" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" -import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { fileURLToPath } from "url" @@ -16,6 +16,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncate" +import { Identifier } from "@/id/id" import { Plugin } from "@/plugin" const MAX_METADATA_LENGTH = 30_000 @@ -117,7 +118,7 @@ export const BashTool = Tool.define("bash", async () => { if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue - const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "") + const resolved = await fs.promises.realpath(path.resolve(cwd, arg)).catch(() => "") log.info("resolved path", { arg, resolved }) if (resolved) { const normalized = @@ -177,7 +178,10 @@ export const BashTool = Tool.define("bash", async () => { windowsHide: process.platform === "win32", }) - let output = "" + let head = "" + let spoolFd: number | undefined + let spoolPath: string | undefined + let totalBytes = 0 // Initialize metadata with empty output ctx.metadata({ @@ -188,11 +192,28 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - output += chunk.toString() + const str = chunk.toString() + totalBytes += str.length + // Keep only enough in memory for the metadata preview and final + // truncated output (Truncate.MAX_BYTES = 50 KB). Everything else + // goes straight to a spool file on disk so the full output is + // recoverable via grep/read without blowing up the heap. + if (head.length < Truncate.MAX_BYTES) { + head += str + } + if (totalBytes > Truncate.MAX_BYTES) { + if (spoolFd === undefined) { + spoolPath = path.join(Truncate.DIR, Identifier.ascending("tool")) + fs.mkdirSync(Truncate.DIR, { recursive: true }) + spoolFd = fs.openSync(spoolPath, "w") + // Flush everything accumulated so far + fs.writeSync(spoolFd, head) + } + fs.writeSync(spoolFd, str) + } 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: head.length > MAX_METADATA_LENGTH ? head.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : head, description: params.description, }, }) @@ -243,28 +264,44 @@ export const BashTool = Tool.define("bash", async () => { }) }) - const resultMetadata: string[] = [] + if (spoolFd !== undefined) fs.closeSync(spoolFd) + + const suffix: string[] = [] if (timedOut) { - resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) + suffix.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } if (aborted) { - resultMetadata.push("User aborted the command") + suffix.push("User aborted the command") + } + + if (suffix.length > 0) { + head += "\n\n\n" + suffix.join("\n") + "\n" } - if (resultMetadata.length > 0) { - output += "\n\n\n" + resultMetadata.join("\n") + "\n" + const meta = { + output: head.length > MAX_METADATA_LENGTH ? head.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : head, + exit: proc.exitCode, + description: params.description, + } + + // If output was spooled to disk, run truncation ourselves and signal + // the Tool.define wrapper to skip its own Truncate.output() pass. + if (spoolPath) { + const preview = head.slice(0, Truncate.MAX_BYTES) + const hint = `The tool call succeeded but the output was truncated. Full output saved to: ${spoolPath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + return { + title: params.description, + metadata: { ...meta, truncated: true as const, outputPath: spoolPath }, + output: `${preview}\n\n...${totalBytes - preview.length} bytes truncated...\n\n${hint}`, + } } return { title: params.description, - metadata: { - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - exit: proc.exitCode, - description: params.description, - }, - output, + metadata: meta, + output: head, } }, } From df64530d674e155f97aa2346e64787d9fd7cb737 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 29 Mar 2026 10:54:28 +0000 Subject: [PATCH 3/6] =?UTF-8?q?perf(edit):=20replace=20O(n=C3=97m)=20Leven?= =?UTF-8?q?shtein=20with=20O(min(n,m))=202-row=20algorithm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/tool/edit.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 554d547d051..ab8df53c393 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -173,24 +173,21 @@ const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0 const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3 /** - * Levenshtein distance algorithm implementation + * Levenshtein distance — 2-row O(min(n,m)) memory instead of full O(n×m) matrix. */ function levenshtein(a: string, b: string): number { - // Handle empty strings - if (a === "" || b === "") { - return Math.max(a.length, b.length) - } - const matrix = Array.from({ length: a.length + 1 }, (_, i) => - Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), - ) - + if (a === "" || b === "") return Math.max(a.length, b.length) + if (a.length < b.length) [a, b] = [b, a] + let prev = Array.from({ length: b.length + 1 }, (_, j) => j) + let curr = new Array(b.length + 1) for (let i = 1; i <= a.length; i++) { + curr[0] = i for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1 - matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost) + curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]) } + ;[prev, curr] = [curr, prev] } - return matrix[a.length][b.length] + return prev[b.length] } export const SimpleReplacer: Replacer = function* (_content, find) { From eddf28f37f8d8b7b7ac7dade6c3f3ff1a756c3b3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 29 Mar 2026 10:54:43 +0000 Subject: [PATCH 4/6] perf(compaction): clear tool output data when compacting parts --- packages/opencode/src/session/compaction.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 223e71639cc..7c7e7e7c622 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -124,6 +124,9 @@ export namespace SessionCompaction { for (const part of toPrune) { if (part.state.status === "completed") { part.state.time.compacted = Date.now() + part.state.output = "[compacted]" + part.state.metadata = {} + part.state.attachments = undefined yield* session.updatePart(part) } } From 0f604d37ec87c022708a350c3ab4478f618ee7cd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 29 Mar 2026 10:55:13 +0000 Subject: [PATCH 5/6] fix: prevent memory leaks in FileTime, LSP diagnostics, and RPC - LSP: delete diagnostic entries when server publishes empty array instead of keeping empty arrays in the map forever - RPC: add 60s timeout to pending calls so leaked promises don't accumulate indefinitely - FileTime: skip (already handled by Effect InstanceState migration) --- packages/opencode/src/lsp/client.ts | 7 ++++++- packages/opencode/src/util/rpc.ts | 15 +++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index de0c4386268..27e749f6641 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -57,7 +57,12 @@ export namespace LSPClient { count: params.diagnostics.length, }) const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) + if (params.diagnostics.length === 0) { + if (!exists) return + diagnostics.delete(filePath) + } else { + diagnostics.set(filePath, params.diagnostics) + } if (!exists && input.serverID === "typescript") return Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) }) diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index ebd8be40e45..9f3279730dc 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -44,10 +44,17 @@ export namespace Rpc { } return { call(method: Method, input: Parameters[0]): Promise> { - const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) - target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + const rid = id++ + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(rid) + reject(new Error(`RPC timeout: ${String(method)}`)) + }, 60_000) + pending.set(rid, (result) => { + clearTimeout(timer) + resolve(result) + }) + target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: rid })) }) }, on(event: string, handler: (data: Data) => void) { From 8cc24ed2244b893a0880c04aca63c222217f75bc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sun, 29 Mar 2026 10:55:27 +0000 Subject: [PATCH 6/6] feat(session): add automatic retention cleanup for archived sessions --- packages/opencode/src/config/config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cbb3416233..fadb5940052 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1056,6 +1056,16 @@ export namespace Config { url: z.string().optional().describe("Enterprise URL"), }) .optional(), + retention: z + .object({ + days: z + .number() + .int() + .min(0) + .optional() + .describe("Auto-delete archived sessions older than this many days (default: 90, 0 = disabled)"), + }) + .optional(), compaction: z .object({ auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),