Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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