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
7 changes: 6 additions & 1 deletion packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,12 @@ export function applyDirectoryEvent(input: {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
const MAX_PART_STRING_LENGTH = 1_048_576 // 1 MB per part field
const combined = (existing ?? "") + props.delta
;(part[field] as string) =
combined.length > MAX_PART_STRING_LENGTH
? combined.slice(combined.length - MAX_PART_STRING_LENGTH)
: combined
}),
)
break
Expand Down
23 changes: 16 additions & 7 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, onCleanup, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
Expand Down Expand Up @@ -668,11 +668,11 @@ function App() {
}
})

sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
const unsub1 = sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})

sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
const unsub2 = sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
title: evt.properties.title,
message: evt.properties.message,
Expand All @@ -681,14 +681,14 @@ function App() {
})
})

sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
const unsub3 = sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
})

sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
const unsub4 = sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
Expand All @@ -698,7 +698,7 @@ function App() {
}
})

sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const unsub5 = sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {
Expand All @@ -720,7 +720,7 @@ function App() {
})
})

sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
const unsub6 = sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
toast.show({
variant: "info",
title: "Update Available",
Expand All @@ -729,6 +729,15 @@ function App() {
})
})

onCleanup(() => {
unsub1()
unsub2()
unsub3()
unsub4()
unsub5()
unsub6()
})

return (
<box
width={dimensions().width}
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function Prompt(props: PromptProps) {
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0

sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return
input.insertText(evt.properties.text)
setTimeout(() => {
Expand All @@ -107,6 +107,7 @@ export function Prompt(props: PromptProps) {
renderer.requestRender()
}, 0)
})
onCleanup(unsubPromptAppend)

createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
For,
Match,
on,
onCleanup,
Show,
Switch,
useContext,
Expand Down Expand Up @@ -204,7 +205,7 @@ export function Session() {
})

let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => {
const unsubPartUpdated = sdk.event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
Expand All @@ -219,6 +220,7 @@ export function Session() {
lastSwitch = part.id
}
})
onCleanup(unsubPartUpdated)

let scroll: ScrollBoxRenderable
let prompt: PromptRef
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export namespace LSPClient {
new StreamMessageWriter(input.server.process.stdin as any),
)

const MAX_DIAGNOSTICS_FILES = 2_000
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
Expand All @@ -56,6 +57,11 @@ export namespace LSPClient {
count: params.diagnostics.length,
})
const exists = diagnostics.has(filePath)
if (!exists && diagnostics.size >= MAX_DIAGNOSTICS_FILES) {
// Evict the oldest entry to stay within the size limit
const oldest = diagnostics.keys().next().value
if (oldest !== undefined) diagnostics.delete(oldest)
}
diagnostics.set(filePath, params.diagnostics)
if (!exists && input.serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
Expand Down Expand Up @@ -132,6 +138,7 @@ export namespace LSPClient {
})
}

const MAX_OPEN_FILES = 1_000
const files: {
[path: string]: number
} = {}
Expand Down Expand Up @@ -191,6 +198,11 @@ export namespace LSPClient {

log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
// Evict oldest tracked file if we're at the limit
if (Object.keys(files).length >= MAX_OPEN_FILES) {
const oldest = Object.keys(files)[0]
if (oldest) delete files[oldest]
}
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(input.path).href,
Expand Down
31 changes: 27 additions & 4 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,27 @@ export namespace MCP {
// Store transports for OAuth servers to allow finishing auth
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
const pendingOAuthTimers = new Map<string, ReturnType<typeof setTimeout>>()
const OAUTH_TRANSPORT_TTL_MS = 5 * 60 * 1_000 // 5 minutes

function setPendingOAuthTransport(key: string, transport: TransportWithAuth) {
const existing = pendingOAuthTimers.get(key)
if (existing) clearTimeout(existing)
pendingOAuthTransports.set(key, transport)
const timer = setTimeout(() => {
pendingOAuthTransports.delete(key)
pendingOAuthTimers.delete(key)
log.info("evicted stale pending OAuth transport", { key })
}, OAUTH_TRANSPORT_TTL_MS)
pendingOAuthTimers.set(key, timer)
}

function deletePendingOAuthTransport(key: string) {
const timer = pendingOAuthTimers.get(key)
if (timer) clearTimeout(timer)
pendingOAuthTimers.delete(key)
pendingOAuthTransports.delete(key)
}

// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
Expand Down Expand Up @@ -205,6 +226,8 @@ export namespace MCP {
}),
),
)
for (const timer of pendingOAuthTimers.values()) clearTimeout(timer)
pendingOAuthTimers.clear()
pendingOAuthTransports.clear()
},
)
Expand Down Expand Up @@ -378,7 +401,7 @@ export namespace MCP {
}).catch((e) => log.debug("failed to show toast", { error: e }))
} else {
// Store transport for later finishAuth call
pendingOAuthTransports.set(key, transport)
setPendingOAuthTransport(key, transport)
status = { status: "needs_auth" as const }
// Show toast for needs_auth
Bus.publish(TuiEvent.ToastShow, {
Expand Down Expand Up @@ -772,7 +795,7 @@ export namespace MCP {
} catch (error) {
if (error instanceof UnauthorizedError && capturedUrl) {
// Store transport for finishAuth
pendingOAuthTransports.set(mcpName, transport)
setPendingOAuthTransport(mcpName, transport)
return { authorizationUrl: capturedUrl.toString() }
}
throw error
Expand Down Expand Up @@ -879,7 +902,7 @@ export namespace MCP {
}

// Re-add the MCP server to establish connection
pendingOAuthTransports.delete(mcpName)
deletePendingOAuthTransport(mcpName)
const result = await add(mcpName, mcpConfig)

const statusRecord = result.status as Record<string, Status>
Expand All @@ -899,7 +922,7 @@ export namespace MCP {
export async function removeAuth(mcpName: string): Promise<void> {
await McpAuth.remove(mcpName)
McpOAuthCallback.cancelPending(mcpName)
pendingOAuthTransports.delete(mcpName)
deletePendingOAuthTransport(mcpName)
await McpAuth.clearOAuthState(mcpName)
log.info("removed oauth credentials", { mcpName })
}
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,18 @@ export namespace PermissionNext {
const s = await state()
return Object.values(s.pending).map((x) => x.info)
}

export async function clearSession(sessionID: string) {
const s = await state()
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID !== sessionID) continue
delete s.pending[id]
pending.reject(new RejectedError())
Bus.publish(Event.Replied, {
sessionID,
requestID: id,
reply: "reject",
})
}
}
}
5 changes: 5 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export namespace SessionCompaction {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
// Clear tool output and attachments from both DB and in-memory store.
// toModelMessages already substitutes "[Old tool result content cleared]"
// for compacted parts, so this aligns stored data with model behavior.
part.state.output = "[compacted]"
part.state.attachments = undefined
await Session.updatePart(part)
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ export namespace Session {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
await PermissionNext.clearSession(sessionID).catch(() => {})
// CASCADE delete handles messages and parts automatically
Database.use((db) => {
db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ export const TaskTool = Tool.define("task", async (ctx) => {
"</task_result>",
].join("\n")

// Clean up subagent session to free in-memory state (messages, parts,
// event listeners). The task output has already been captured above.
// If the LLM later tries to resume via task_id, Session.get() will
// fail gracefully and a fresh session will be created instead.
Session.remove(session.id).catch(() => {})

return {
title: params.description,
metadata: {
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/js/src/gen/core/serverSentEvents.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,16 @@ export const createSseClient = <TData = unknown>({

signal.addEventListener("abort", abortHandler)

const MAX_BUFFER_SIZE = 10_485_760 // 10 MB
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (buffer.length + value.length > MAX_BUFFER_SIZE) {
throw new Error(
`SSE buffer overflow: accumulated ${buffer.length + value.length} bytes (limit ${MAX_BUFFER_SIZE})`,
)
}
buffer += value

const chunks = buffer.split("\n\n")
Expand Down
12 changes: 8 additions & 4 deletions packages/sdk/js/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,14 @@ export async function createOpencodeServer(options?: ServerOptions) {
reject(error)
})
if (options.signal) {
options.signal.addEventListener("abort", () => {
clearTimeout(id)
reject(new Error("Aborted"))
})
options.signal.addEventListener(
"abort",
() => {
clearTimeout(id)
reject(new Error("Aborted"))
},
{ once: true },
)
}
})

Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,16 @@ export const createSseClient = <TData = unknown>({

signal.addEventListener("abort", abortHandler)

const MAX_BUFFER_SIZE = 10_485_760 // 10 MB
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (buffer.length + value.length > MAX_BUFFER_SIZE) {
throw new Error(
`SSE buffer overflow: accumulated ${buffer.length + value.length} bytes (limit ${MAX_BUFFER_SIZE})`,
)
}
buffer += value
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
Expand Down
12 changes: 8 additions & 4 deletions packages/sdk/js/src/v2/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,14 @@ export async function createOpencodeServer(options?: ServerOptions) {
reject(error)
})
if (options.signal) {
options.signal.addEventListener("abort", () => {
clearTimeout(id)
reject(new Error("Aborted"))
})
options.signal.addEventListener(
"abort",
() => {
clearTimeout(id)
reject(new Error("Aborted"))
},
{ once: true },
)
}
})

Expand Down
Loading