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
20 changes: 11 additions & 9 deletions packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,18 @@ export namespace Bus {
},
async (entry) => {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
}
for (const sub of [...wildcard]) {
sub(event)
if (wildcard) {
const event = {
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
}
for (const sub of [...wildcard]) {
sub(event)
}
}
entry.subscriptions.clear()
},
)

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 @@ -673,11 +673,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 @@ -686,14 +686,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 @@ -703,7 +703,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 @@ -725,7 +725,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 @@ -734,6 +734,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
16 changes: 15 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 All @@ -31,6 +32,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import { formatSize } from "@/util/format"
import type { Tool } from "@/tool/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
Expand Down Expand Up @@ -208,7 +210,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 @@ -223,6 +225,7 @@ export function Session() {
lastSwitch = part.id
}
})
onCleanup(unsubPartUpdated)

let scroll: ScrollBoxRenderable
let prompt: PromptRef
Expand Down Expand Up @@ -1737,6 +1740,14 @@ function Bash(props: ToolProps<typeof BashTool>) {
return [...lines().slice(0, 10), "…"].join("\n")
})

const filterInfo = createMemo(() => {
if (!props.metadata.filtered) return undefined
const total = formatSize(props.metadata.totalBytes ?? 0)
const omitted = formatSize(props.metadata.omittedBytes ?? 0)
const matches = props.metadata.matchCount ?? 0
return `Filtered: ${matches} match${matches === 1 ? "" : "es"} from ${total} (${omitted} omitted)`
})

const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
Expand Down Expand Up @@ -1776,6 +1787,9 @@ function Bash(props: ToolProps<typeof BashTool>) {
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={filterInfo()}>
<text fg={theme.textMuted}>{filterInfo()}</text>
</Show>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
Expand Down
8 changes: 1 addition & 7 deletions packages/opencode/src/cli/cmd/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { Global } from "../../global"
import { formatSize } from "../../util/format"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
Expand Down Expand Up @@ -340,13 +341,6 @@ async function getDirectorySize(dir: string): Promise<number> {
return total
}

function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}

function shortenPath(p: string): string {
const home = os.homedir()
if (p.startsWith(home)) {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
import { Instance } from "./project/instance"

process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
Expand Down Expand Up @@ -202,6 +203,12 @@ try {
}
process.exitCode = 1
} finally {
// Dispose all instance-scoped resources (LSP clients, bus subscriptions, PTY sessions, etc.)
try {
await Instance.disposeAll()
} catch {
// best-effort cleanup
}
// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
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",
})
}
}
}
Loading
Loading