+
(scrollRef = el)}>
{
@@ -1139,23 +1237,56 @@ export const PromptInput: Component
= (props) => {
)
}
+function createTextFragment(content: string): DocumentFragment {
+ const fragment = document.createDocumentFragment()
+ const segments = content.split("\n")
+ segments.forEach((segment, index) => {
+ if (segment) {
+ fragment.appendChild(document.createTextNode(segment))
+ } else if (segments.length > 1) {
+ fragment.appendChild(document.createTextNode("\u200B"))
+ }
+ if (index < segments.length - 1) {
+ fragment.appendChild(document.createElement("br"))
+ }
+ })
+ return fragment
+}
+
+function getNodeLength(node: Node): number {
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+function getTextLength(node: Node): number {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ let length = 0
+ for (const child of Array.from(node.childNodes)) {
+ length += getTextLength(child)
+ }
+ return length
+}
+
function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
+ if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset)
- return preCaretRange.toString().length
+ return getTextLength(preCaretRange.cloneContents())
}
function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position
let node = parent.firstChild
while (node) {
- const length = node.textContent ? node.textContent.length : 0
+ const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
const range = document.createRange()
@@ -1167,10 +1298,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return
}
- if (isFile && remaining <= length) {
+ if ((isFile || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
- range.setStartAfter(node)
+ if (remaining === 0) {
+ range.setStartBefore(node)
+ }
+ if (remaining > 0 && isFile) {
+ range.setStartAfter(node)
+ }
+ if (remaining > 0 && isBreak) {
+ const next = node.nextSibling
+ if (next && next.nodeType === Node.TEXT_NODE) {
+ range.setStart(next, 0)
+ }
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(node)
+ }
+ }
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx
new file mode 100644
index 00000000000..98d6d6dfd76
--- /dev/null
+++ b/packages/app/src/components/session-lsp-indicator.tsx
@@ -0,0 +1,40 @@
+import { createMemo, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useSync } from "@/context/sync"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+
+export function SessionLspIndicator() {
+ const sync = useSync()
+
+ const lspStats = createMemo(() => {
+ const lsp = sync.data.lsp ?? []
+ const connected = lsp.filter((s) => s.status === "connected").length
+ const hasError = lsp.some((s) => s.status === "error")
+ const total = lsp.length
+ return { connected, hasError, total }
+ })
+
+ const tooltipContent = createMemo(() => {
+ const lsp = sync.data.lsp ?? []
+ if (lsp.length === 0) return "No LSP servers"
+ return lsp.map((s) => s.name).join(", ")
+ })
+
+ return (
+ 0}>
+
+
+ 0,
+ }}
+ />
+ {lspStats().connected} LSP
+
+
+
+ )
+}
diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx
new file mode 100644
index 00000000000..17a6f2e1af0
--- /dev/null
+++ b/packages/app/src/components/session-mcp-indicator.tsx
@@ -0,0 +1,36 @@
+import { createMemo, Show } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useSync } from "@/context/sync"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
+
+export function SessionMcpIndicator() {
+ const sync = useSync()
+ const dialog = useDialog()
+
+ const mcpStats = createMemo(() => {
+ const mcp = sync.data.mcp ?? {}
+ const entries = Object.entries(mcp)
+ const enabled = entries.filter(([, status]) => status.status === "connected").length
+ const failed = entries.some(([, status]) => status.status === "failed")
+ const total = entries.length
+ return { enabled, failed, total }
+ })
+
+ return (
+ 0}>
+
+
+ )
+}
diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx
new file mode 100644
index 00000000000..d8a88503f20
--- /dev/null
+++ b/packages/app/src/components/status-bar.tsx
@@ -0,0 +1,32 @@
+import { createMemo, Show, type ParentProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
+
+export function StatusBar(props: ParentProps) {
+ const platform = usePlatform()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+
+ const directoryDisplay = createMemo(() => {
+ const directory = sync.data.path.directory || ""
+ const home = globalSync.data.path.home || ""
+ const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
+ const branch = sync.data.vcs?.branch
+ return branch ? `${short}:${branch}` : short
+ })
+
+ return (
+
+
+
+ v{platform.version}
+
+
+ {directoryDisplay()}
+
+
+
{props.children}
+
+ )
+}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index e143f701197..b103b589182 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -203,13 +203,15 @@ export const Terminal = (props: TerminalProps) => {
ws.addEventListener("open", () => {
if (!isMounted) return
console.log("WebSocket connected")
- sdk.client.pty.update({
- ptyID: local.pty.id,
- size: {
- cols: term.cols,
- rows: term.rows,
- },
- })
+ sdk.client.pty
+ .update({
+ ptyID: local.pty.id,
+ size: {
+ cols: term.cols,
+ rows: term.rows,
+ },
+ })
+ .catch(() => {})
})
ws.addEventListener("message", (event) => {
if (!isMounted) return
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index c0fc3ec6bfa..15fc3908170 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -14,6 +14,10 @@ import {
type ProviderListResponse,
type ProviderAuthResponse,
type Command,
+ type McpStatus,
+ type LspStatus,
+ type VcsInfo,
+ type Permission,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -45,6 +49,14 @@ type State = {
}
changes: File[]
node: FileNode[]
+ permission: {
+ [sessionID: string]: Permission[]
+ }
+ mcp: {
+ [name: string]: McpStatus
+ }
+ lsp: LspStatus[]
+ vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
@@ -74,6 +86,7 @@ function createGlobalSync() {
})
const children: Record>> = {}
+ const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set()
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
@@ -91,6 +104,10 @@ function createGlobalSync() {
todo: {},
changes: [],
node: [],
+ permission: {},
+ mcp: {},
+ lsp: [],
+ vcs: undefined,
limit: 10,
message: {},
part: {},
@@ -155,6 +172,18 @@ function createGlobalSync() {
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
+ mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
+ lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
+ vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
+ permission: () =>
+ sdk.permission.list().then((x) => {
+ const grouped: Record = {}
+ for (const perm of x.data ?? []) {
+ grouped[perm.sessionID] = grouped[perm.sessionID] ?? []
+ grouped[perm.sessionID]!.push(perm)
+ }
+ setStore("permission", grouped)
+ }),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -301,6 +330,50 @@ function createGlobalSync() {
}
break
}
+ case "vcs.branch.updated": {
+ setStore("vcs", { branch: event.properties.branch })
+ break
+ }
+ case "permission.updated": {
+ const permissions = store.permission[event.properties.sessionID]
+ const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id)
+ if (!permissions) {
+ setStore("permission", event.properties.sessionID, [event.properties])
+ } else {
+ const result = Binary.search(permissions, event.properties.id, (p) => p.id)
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ if (result.found) {
+ draft[result.index] = event.properties
+ return
+ }
+ draft.push(event.properties)
+ }),
+ )
+ }
+ if (isNew) {
+ for (const listener of permissionListeners) {
+ listener({ directory, permission: event.properties })
+ }
+ }
+ break
+ }
+ case "permission.replied": {
+ const permissions = store.permission[event.properties.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
+ if (!result.found) break
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
}
})
@@ -373,6 +446,12 @@ function createGlobalSync() {
project: {
loadSessions,
},
+ permission: {
+ onUpdated(listener: (info: { directory: string; permission: Permission }) => void) {
+ permissionListeners.add(listener)
+ return () => permissionListeners.delete(listener)
+ },
+ },
}
}
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 600a0e4b160..49217b82be8 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const list = async (path: string) => {
- return sdk.client.file.list({ path: path + "/" }).then((x) => {
- setStore(
- "node",
- produce((draft) => {
- x.data!.forEach((node) => {
- if (node.path in draft) return
- draft[node.path] = node
- })
- }),
- )
- })
+ return sdk.client.file
+ .list({ path: path + "/" })
+ .then((x) => {
+ setStore(
+ "node",
+ produce((draft) => {
+ x.data!.forEach((node) => {
+ if (node.path in draft) return
+ draft[node.path] = node
+ })
+ }),
+ )
+ })
+ .catch(() => {})
}
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 6f7c11dea8c..e9a07077cef 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
- sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
- const id = pty.data?.id
- if (!id) return
- setStore("all", [
- ...store.all,
- {
- id,
- title: pty.data?.title ?? "Terminal",
- },
- ])
- setStore("active", id)
- })
+ sdk.client.pty
+ .create({ title: `Terminal ${store.all.length + 1}` })
+ .then((pty) => {
+ const id = pty.data?.id
+ if (!id) return
+ setStore("all", [
+ ...store.all,
+ {
+ id,
+ title: pty.data?.title ?? "Terminal",
+ },
+ ])
+ setStore("active", id)
+ })
+ .catch((e) => {
+ console.error("Failed to create terminal", e)
+ })
},
update(pty: Partial & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
- sdk.client.pty.update({
- ptyID: pty.id,
- title: pty.title,
- size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
- })
+ sdk.client.pty
+ .update({
+ ptyID: pty.id,
+ title: pty.title,
+ size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+ })
+ .catch((e) => {
+ console.error("Failed to update terminal", e)
+ })
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
- const clone = await sdk.client.pty.create({
- title: pty.title,
- })
- if (!clone.data) return
+ const clone = await sdk.client.pty
+ .create({
+ title: pty.title,
+ })
+ .catch((e) => {
+ console.error("Failed to clone terminal", e)
+ return undefined
+ })
+ if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
setStore("active", previous?.id)
}
})
- await sdk.client.pty.remove({ ptyID: id })
+ await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
+ console.error("Failed to close terminal", e)
+ })
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index c909a373d56..04f90bdcbf6 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,6 +1,6 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
-import { SDKProvider } from "@/context/sdk"
+import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
@@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) {
{iife(() => {
const sync = useSync()
+ const sdk = useSDK()
return (
-
+ {
+ sdk.client.permission.respond(input)
+ }}
+ >
{props.children}
)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 33b66969734..069c3fbe5c9 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, untrack, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, untrack, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
@@ -102,6 +102,39 @@ export default function Layout(props: ParentProps) {
const currentSessionId = createMemo(() => currentSession()?.id)
const otherSessions = createMemo(() => sessions().filter((s) => s.id !== currentSessionId()))
+ onMount(() => {
+ const unsub = globalSync.permission.onUpdated(({ directory, permission }) => {
+ const currentDir = params.dir ? base64Decode(params.dir) : undefined
+ const currentSession = params.id
+ if (directory === currentDir && permission.sessionID === currentSession) return
+ const [store] = globalSync.child(directory)
+ const session = store.session.find((s) => s.id === permission.sessionID)
+ if (directory === currentDir && session?.parentID === currentSession) return
+ const sessionTitle = session?.title ?? "New session"
+ const projectName = getFilename(directory)
+ showToast({
+ persistent: true,
+ icon: "checklist",
+ title: "Permission required",
+ description: `${sessionTitle} in ${projectName} needs permission`,
+ actions: [
+ {
+ label: "Go to session",
+ onClick: () => {
+ navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
+ },
+ dismissAfter: true,
+ },
+ {
+ label: "Dismiss",
+ onClick: "dismiss",
+ },
+ ],
+ })
+ })
+ onCleanup(unsub)
+ })
+
function flattenSessions(sessions: Session[]): Session[] {
const childrenMap = new Map()
for (const session of sessions) {
@@ -124,6 +157,19 @@ export default function Layout(props: ParentProps) {
return result
}
+ function sortSessions(a: Session, b: Session) {
+ const now = Date.now()
+ const oneMinuteAgo = now - 60 * 1000
+ const aUpdated = a.time.updated ?? a.time.created
+ const bUpdated = b.time.updated ?? b.time.created
+ const aRecent = aUpdated > oneMinuteAgo
+ const bRecent = bUpdated > oneMinuteAgo
+ if (aRecent && bRecent) return a.id.localeCompare(b.id)
+ if (aRecent && !bRecent) return -1
+ if (!aRecent && bRecent) return 1
+ return bUpdated - aUpdated
+ }
+
function scrollToSession(sessionId: string) {
if (!scrollContainerRef) return
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
@@ -451,8 +497,20 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ const hasPermissions = createMemo(() => {
+ const store = globalSync.child(props.project.worktree)[0]
+ const permissions = store.permission?.[props.session.id] ?? []
+ if (permissions.length > 0) return true
+ const childSessions = store.session.filter((s) => s.parentID === props.session.id)
+ for (const child of childSessions) {
+ const childPermissions = store.permission?.[child.id] ?? []
+ if (childPermissions.length > 0) return true
+ }
+ return false
+ })
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
+ if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
@@ -483,6 +541,9 @@ export default function Layout(props: ParentProps) {
+
+
+
@@ -611,7 +672,7 @@ export default function Layout(props: ParentProps) {
closeProject(props.project.worktree)}>
- Close Project
+ Close project
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c37bdae4aad..ab2321e461f 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -51,6 +51,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSessionRename } from "@/components/dialog-session-rename"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage, ToolPart } from "@opencode-ai/sdk/v2"
@@ -59,6 +60,9 @@ import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { AskQuestionWizard, type AskQuestionQuestion, type AskQuestionAnswer } from "@/components/askquestion-wizard"
+import { StatusBar } from "@/components/status-bar"
+import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
+import { SessionLspIndicator } from "@/components/session-lsp-indicator"
export default function Page() {
const layout = useLayout()
@@ -357,6 +361,15 @@ export default function Page() {
slash: "model",
onSelect: () => dialog.show(() => ),
},
+ {
+ id: "mcp.toggle",
+ title: "Toggle MCPs",
+ description: "Toggle MCPs",
+ category: "MCP",
+ keybind: "mod+;",
+ slash: "mcp",
+ onSelect: () => dialog.show(() => ),
+ },
{
id: "agent.cycle",
title: "Cycle agent",
@@ -1291,6 +1304,10 @@ export default function Page() {
+
+
+
+
)
}
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index a12dc87f24d..4474366b880 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.0.203",
+ "version": "1.0.204",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts
index a793b85962a..2f8781e9882 100644
--- a/packages/console/app/src/routes/auth/callback.ts
+++ b/packages/console/app/src/routes/auth/callback.ts
@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
- const code = url.searchParams.get("code")
- if (!code) throw new Error("No code found")
- const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
- if (result.err) {
- throw new Error(result.err.message)
- }
- const decoded = AuthClient.decode(result.tokens.access, {} as any)
- if (decoded.err) throw new Error(decoded.err.message)
- const session = await useAuthSession()
- const id = decoded.subject.properties.accountID
- await session.update((value) => {
- return {
- ...value,
- account: {
- ...value.account,
- [id]: {
- id,
- email: decoded.subject.properties.email,
+ try {
+ const code = url.searchParams.get("code")
+ if (!code) throw new Error("No code found")
+ const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
+ if (result.err) throw new Error(result.err.message)
+ const decoded = AuthClient.decode(result.tokens.access, {} as any)
+ if (decoded.err) throw new Error(decoded.err.message)
+ const session = await useAuthSession()
+ const id = decoded.subject.properties.accountID
+ await session.update((value) => {
+ return {
+ ...value,
+ account: {
+ ...value.account,
+ [id]: {
+ id,
+ email: decoded.subject.properties.email,
+ },
},
- },
- current: id,
- }
- })
- return redirect("/auth")
+ current: id,
+ }
+ })
+ return redirect("/auth")
+ } catch (e: any) {
+ return new Response(
+ JSON.stringify({
+ error: e.message,
+ cause: Object.fromEntries(url.searchParams.entries()),
+ }),
+ { status: 500 },
+ )
+ }
}
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 4f6d2717fb7..f74d28b2e32 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.0.203",
+ "version": "1.0.204",
"private": true,
"type": "module",
"dependencies": {
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index 572a86ddd5e..57b004fb709 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.0.203",
+ "version": "1.0.204",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts
index 742e0d567ce..082564b21ce 100644
--- a/packages/console/function/src/auth.ts
+++ b/packages/console/function/src/auth.ts
@@ -123,7 +123,11 @@ export default {
},
}).then((x) => x.json())) as any
subject = user.id.toString()
- email = emails.find((x: any) => x.primary && x.verified)?.email
+
+ const primaryEmail = emails.find((x: any) => x.primary)
+ if (!primaryEmail) throw new Error("No primary email found for GitHub user")
+ if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified")
+ email = primaryEmail.email
} else if (response.provider === "google") {
if (!response.id.email_verified) throw new Error("Google email not verified")
subject = response.id.sub as string
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 1b2869dd9ec..f2c7c7302f9 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.0.203",
+ "version": "1.0.204",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 4bdb5ce3886..23aa11091fb 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.0.203",
+ "version": "1.0.204",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts
index 123a2028c91..6d4f62dc2cb 100644
--- a/packages/desktop/vite.config.ts
+++ b/packages/desktop/vite.config.ts
@@ -10,6 +10,9 @@ export default defineConfig({
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
+ build: {
+ sourcemap: true,
+ },
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index a89e5df7ef7..e4a7f45beae 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.0.203",
+ "version": "1.0.204",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index e21818e4629..3e01e835339 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.0.203"
+version = "1.0.204"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-linux-x64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-windows-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index 160e78b35fd..44c6ef110ef 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.0.203",
+ "version": "1.0.204",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index fda23d865b3..539f604a5d9 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.0.203",
+ "version": "1.0.204",
"name": "opencode",
"type": "module",
"private": true,
@@ -52,14 +52,21 @@
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/azure": "2.0.73",
+ "@ai-sdk/cerebras": "1.0.33",
+ "@ai-sdk/cohere": "2.0.21",
+ "@ai-sdk/deepinfra": "1.0.30",
+ "@ai-sdk/gateway": "2.0.23",
"@ai-sdk/google": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81",
+ "@ai-sdk/groq": "2.0.33",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
+ "@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
+ "@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
@@ -81,6 +88,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
+ "bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
diff --git a/packages/opencode/src/agent/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt
index f67aaa95bac..7e927b797ce 100644
--- a/packages/opencode/src/agent/prompt/title.txt
+++ b/packages/opencode/src/agent/prompt/title.txt
@@ -22,7 +22,7 @@ Your output must be:
- The title should NEVER include "summarizing" or "generating" when generating a title
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal.
-- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"):
+- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts
index c607e5f5bb7..060d0d5a156 100644
--- a/packages/opencode/src/cli/cmd/acp.ts
+++ b/packages/opencode/src/cli/cmd/acp.ts
@@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" })
@@ -19,29 +20,16 @@ export const AcpCommand = cmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
builder: (yargs) => {
- return yargs
- .option("cwd", {
- describe: "working directory",
- type: "string",
- default: process.cwd(),
- })
- .option("port", {
- type: "number",
- describe: "port to listen on",
- default: 0,
- })
- .option("hostname", {
- type: "string",
- describe: "hostname to listen on",
- default: "127.0.0.1",
- })
+ return withNetworkOptions(yargs).option("cwd", {
+ describe: "working directory",
+ type: "string",
+ default: process.cwd(),
+ })
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
- const server = Server.listen({
- port: args.port,
- hostname: args.hostname,
- })
+ const opts = await resolveNetworkOptions(args)
+ const server = Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index 773de8f7f0f..d6bd84798de 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/packages/opencode/src/cli/cmd/github.ts
@@ -9,7 +9,9 @@ import * as github from "@actions/github"
import type { Context } from "@actions/github/lib/context"
import type {
IssueCommentEvent,
+ IssuesEvent,
PullRequestReviewCommentEvent,
+ WorkflowDispatchEvent,
WorkflowRunEvent,
PullRequestEvent,
} from "@octokit/webhooks-types"
@@ -132,7 +134,16 @@ type IssueQueryResponse = {
const AGENT_USERNAME = "opencode-agent[bot]"
const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
-const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const
+
+// Event categories for routing
+// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments
+// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only
+const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const
+const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const
+const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
+
+type UserEvent = (typeof USER_EVENTS)[number]
+type RepoEvent = (typeof REPO_EVENTS)[number]
// Parses GitHub remote URLs in various formats:
// - https://github.com/owner/repo.git
@@ -397,27 +408,38 @@ export const GithubRunCommand = cmd({
core.setFailed(`Unsupported event type: ${context.eventName}`)
process.exit(1)
}
+
+ // Determine event category for routing
+ // USER_EVENTS: have actor, issueId, support reactions/comments
+ // REPO_EVENTS: no actor/issueId, output to logs/PR only
+ const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent)
+ const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent)
const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName)
+ const isIssuesEvent = context.eventName === "issues"
const isScheduleEvent = context.eventName === "schedule"
+ const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel()
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo
- // For schedule events, payload has no issue/comment data
+ // For repo events (schedule, workflow_dispatch), payload has no issue/comment data
const payload = context.payload as
| IssueCommentEvent
+ | IssuesEvent
| PullRequestReviewCommentEvent
+ | WorkflowDispatchEvent
| WorkflowRunEvent
| PullRequestEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
+ // workflow_dispatch has an actor (the user who triggered it), schedule does not
const actor = isScheduleEvent ? undefined : context.actor
- const issueId = isScheduleEvent
+ const issueId = isRepoEvent
? undefined
- : context.eventName === "issue_comment"
- ? (payload as IssueCommentEvent).issue.number
+ : context.eventName === "issue_comment" || context.eventName === "issues"
+ ? (payload as IssueCommentEvent | IssuesEvent).issue.number
: (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
const shareBaseUrl = isMock ? "https://share.dev.shuv.ai" : "https://share.shuv.ai"
@@ -462,8 +484,8 @@ export const GithubRunCommand = cmd({
if (!useGithubToken) {
await configureGit(appToken)
}
- // Skip permission check for schedule events (no actor to check)
- if (!isScheduleEvent) {
+ // Skip permission check and reactions for repo events (no actor to check, no issue to react to)
+ if (isUserEvent) {
await assertPermissions()
await addReaction(commentType)
}
@@ -480,25 +502,30 @@ export const GithubRunCommand = cmd({
})()
console.log("opencode session", session.id)
- // Handle 4 cases
- // 1. Schedule (no issue/PR context)
- // 2. Issue
- // 3. Local PR
- // 4. Fork PR
- if (isScheduleEvent) {
- // Schedule event - no issue/PR context, output goes to logs
- const branch = await checkoutNewBranch("schedule")
+ // Handle event types:
+ // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only
+ // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch
+ // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR
+ if (isRepoEvent) {
+ // Repo event - no issue/PR context, output goes to logs
+ if (isWorkflowDispatchEvent && actor) {
+ console.log(`Triggered by: ${actor}`)
+ }
+ const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
+ const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
- await pushToNewBranch(summary, branch, uncommittedChanges, true)
+ // workflow_dispatch has an actor for co-author attribution, schedule does not
+ await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
+ const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow"
const pr = await createPR(
repoData.data.default_branch,
branch,
summary,
- `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
+ `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
)
console.log(`Created PR #${pr}`)
} else {
@@ -573,7 +600,7 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) {
msg = e.message
}
- if (!isScheduleEvent) {
+ if (isUserEvent) {
await createComment(`${msg}${footer()}`)
await removeReaction(commentType)
}
@@ -628,9 +655,15 @@ export const GithubRunCommand = cmd({
}
function isIssueCommentEvent(
- event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent,
+ event:
+ | IssueCommentEvent
+ | IssuesEvent
+ | PullRequestReviewCommentEvent
+ | WorkflowDispatchEvent
+ | WorkflowRunEvent
+ | PullRequestEvent,
): event is IssueCommentEvent {
- return "issue" in event
+ return "issue" in event && "comment" in event
}
function getReviewCommentContext() {
@@ -652,10 +685,11 @@ export const GithubRunCommand = cmd({
async function getUserPrompt() {
const customPrompt = process.env["PROMPT"]
- // For schedule events, PROMPT is required since there's no comment to extract from
- if (isScheduleEvent) {
+ // For repo events and issues events, PROMPT is required since there's no comment to extract from
+ if (isRepoEvent || isIssuesEvent) {
if (!customPrompt) {
- throw new Error("PROMPT input is required for scheduled events")
+ const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues"
+ throw new Error(`PROMPT input is required for ${eventType} events`)
}
return { userPrompt: customPrompt, promptFiles: [] }
}
@@ -942,7 +976,7 @@ export const GithubRunCommand = cmd({
await $`git config --local ${config} "${gitConfig}"`
}
- async function checkoutNewBranch(type: "issue" | "schedule") {
+ async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
@@ -971,16 +1005,16 @@ export const GithubRunCommand = cmd({
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
}
- function generateBranchName(type: "issue" | "pr" | "schedule") {
+ function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
const timestamp = new Date()
.toISOString()
.replace(/[:-]/g, "")
.replace(/\.\d{3}Z/, "")
.split("T")
.join("")
- if (type === "schedule") {
+ if (type === "schedule" || type === "dispatch") {
const hex = crypto.randomUUID().slice(0, 6)
- return `opencode/scheduled-${hex}-${timestamp}`
+ return `opencode/${type}-${hex}-${timestamp}`
}
return `opencode/${type}${issueId}-${timestamp}`
}
diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts
index 0236b3d64be..aa738962df6 100644
--- a/packages/opencode/src/cli/cmd/serve.ts
+++ b/packages/opencode/src/cli/cmd/serve.ts
@@ -1,29 +1,14 @@
import { Server } from "../../server/server"
import { cmd } from "./cmd"
+import { withNetworkOptions, resolveNetworkOptions } from "../network"
export const ServeCommand = cmd({
command: "serve",
- builder: (yargs) =>
- yargs
- .option("port", {
- alias: ["p"],
- type: "number",
- describe: "port to listen on",
- default: 0,
- })
- .option("hostname", {
- type: "string",
- describe: "hostname to listen on",
- default: "127.0.0.1",
- }),
+ builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
handler: async (args) => {
- const hostname = args.hostname
- const port = args.port
- const server = Server.listen({
- port,
- hostname,
- })
+ const opts = await resolveNetworkOptions(args)
+ const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
const stop = async () => {
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 8b0b09b032b..aff3deae878 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -584,7 +584,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
const message = (() => {
- if (!error) return "An error occured"
+ if (!error) return "An error occurred"
if (typeof error === "object") {
const data = error.data
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
index 1217bb54ae0..cb7b5d282ee 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
-import { createEffect, createMemo, createSignal, onMount } from "solid-js"
+import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
+import { useKV } from "../context/kv"
import "opentui-spinner/solid"
export function DialogSessionList() {
@@ -16,6 +17,7 @@ export function DialogSessionList() {
const { theme } = useTheme()
const route = useRoute()
const sdk = useSDK()
+ const kv = useKV()
const [toDelete, setToDelete] = createSignal
()
@@ -45,7 +47,11 @@ export function DialogSessionList() {
value: x.id,
category,
footer: Locale.time(x.time.updated),
- gutter: isWorking ? : undefined,
+ gutter: isWorking ? (
+ [⋯]}>
+
+
+ ) : undefined,
}
})
.slice(0, 150)
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index d191c4976bb..6b1eb063cd2 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -28,6 +28,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
+import { useKV } from "../../context/kv"
// Regex to match optional whitespace followed by #L[-] line range syntax after a file reference
// Only matches when followed by a space (confirming the line range is complete)
@@ -129,6 +130,7 @@ export function Prompt(props: PromptProps) {
const tall = createMemo(() => dimensions().height > 40)
const wide = createMemo(() => dimensions().width > 120)
const { theme, syntax } = useTheme()
+ const kv = useKV()
function promptModelWarning() {
toast.show({
@@ -1168,8 +1170,11 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
- {/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
-
+
+ [⋯]}>
+
+
+
{(() => {
const retry = createMemo(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 71e3b444188..2588aef264c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -250,6 +250,7 @@ export function Session() {
const [headerVisible, setHeaderVisible] = createSignal(kv.get("header_visible", true))
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
+ const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
// Initialize spinner style and interval from KV store
const savedSpinnerStyle = kv.get("spinner_style", DEFAULT_SPINNER_KEY)
@@ -888,6 +889,19 @@ export function Session() {
dialog.clear()
},
},
+ {
+ title: animationsEnabled() ? "Disable animations" : "Enable animations",
+ value: "session.toggle.animations",
+ category: "Session",
+ onSelect: (dialog) => {
+ setAnimationsEnabled((prev) => {
+ const next = !prev
+ kv.set("animations_enabled", next)
+ return next
+ })
+ dialog.clear()
+ },
+ },
{
title: "Page up",
value: "session.page.up",
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 9883bdb3f60..8cc52ab8c2c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -11,7 +11,6 @@ import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { getSpinnerFrame } from "../../util/spinners"
import { useToast } from "../../ui/toast"
-import { TodoItem } from "../../component/todo-item"
export function Sidebar(props: { sessionID: string; width: number }) {
const sync = useSync()
@@ -20,13 +19,13 @@ export function Sidebar(props: { sessionID: string; width: number }) {
const toast = useToast()
const session = createMemo(() => sync.session.get(props.sessionID))
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
- const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
+
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const [expanded, setExpanded] = createStore({
+ context: true,
mcp: true,
diff: true,
- todo: true,
lsp: true,
subagents: true,
})
@@ -115,24 +114,108 @@ export function Sidebar(props: { sessionID: string; width: number }) {
{session()?.share?.url}
+{/* Context Section */}
-
- Context
-
- {context()?.tokens ?? 0} tokens
- {context()?.percentage ?? 0}% used
- {cost()} spent
+ setExpanded("context", !expanded.context)}>
+ {expanded.context ? "▼" : "▶"}
+
+ Context
+
+ ({context()?.tokens ?? 0} tokens)
+
+
+
+
+ {context()?.tokens ?? 0} tokens
+ {context()?.percentage ?? 0}% used
+ {cost()} spent
+
+
+ {/* Subagents Section */}
+ 0}>
+
+ setExpanded("subagents", !expanded.subagents)}>
+ {expanded.subagents ? "▼" : "▶"}
+
+ Subagents
+
+ ({subagentGroups().length} types)
+
+
+
+
+
+ {([agentName, parts]) => {
+ const hasActive = () =>
+ parts.some((p) => p.state.status === "running" || p.state.status === "pending")
+ return (
+
+
+
+ •
+
+
+ {agentName}
+
+
+
+ {(part) => {
+ const isActive = () => part.state.status === "running" || part.state.status === "pending"
+ const isError = () => part.state.status === "error"
+ const input = part.state.input as Record
+ const description = (input?.description as string) ?? ""
+
+ // Get subagent session ID from metadata, not part.sessionID (which is the parent)
+ const metadata =
+ part.state.status === "completed"
+ ? part.state.metadata
+ : ((part.state as { metadata?: Record }).metadata ?? {})
+ const subagentSessionId = (metadata?.sessionId as string) ?? undefined
+
+ return (
+ {
+ if (subagentSessionId) {
+ try {
+ await sync.session.sync(subagentSessionId)
+ route.navigate({ type: "session", sessionID: subagentSessionId })
+ } catch (e) {
+ console.error("Failed to sync subagent session:", e)
+ toast.show({
+ message: `Session not found`,
+ variant: "error",
+ })
+ }
+ }
+ }}
+ >
+
+ {isActive() ? getSpinnerFrame() : isError() ? "✗" : "✓"}
+
+
+ {description}
+
+
+ )
+ }}
+
+
+ )
+ }}
+
+
+
+
+
+ {/* MCP Section */}
0}>
- mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
- >
- 2}>
- {expanded.mcp ? "▼" : "▶"}
-
+ setExpanded("mcp", !expanded.mcp)}>
+ {expanded.mcp ? "▼" : "▶"}
MCP
@@ -144,7 +227,7 @@ export function Sidebar(props: { sessionID: string; width: number }) {
-
+
{([key, item]) => (
@@ -184,20 +267,19 @@ export function Sidebar(props: { sessionID: string; width: number }) {
+
+ {/* LSP Section */}
- sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
- >
- 2}>
- {expanded.lsp ? "▼" : "▶"}
-
+ setExpanded("lsp", !expanded.lsp)}>
+ {expanded.lsp ? "▼" : "▶"}
LSP
+
+ ({sync.data.lsp.length} active)
+
-
+
{sync.data.config.lsp === false
@@ -227,120 +309,20 @@ export function Sidebar(props: { sessionID: string; width: number }) {
- 0}>
-
- subagentGroups().length > 2 && setExpanded("subagents", !expanded.subagents)}
- >
- 2}>
- {expanded.subagents ? "▼" : "▶"}
-
-
- Subagents
-
-
-
-
- {([agentName, parts]) => {
- const hasActive = () =>
- parts.some((p) => p.state.status === "running" || p.state.status === "pending")
- return (
-
-
-
- •
-
-
- {agentName}
-
-
-
- {(part) => {
- const isActive = () => part.state.status === "running" || part.state.status === "pending"
- const isError = () => part.state.status === "error"
- const input = part.state.input as Record
- const description = (input?.description as string) ?? ""
- // Get subagent session ID from metadata, not part.sessionID (which is the parent)
- const metadata =
- part.state.status === "completed"
- ? part.state.metadata
- : ((part.state as { metadata?: Record }).metadata ?? {})
- const subagentSessionId = (metadata?.sessionId as string) ?? undefined
-
- return (
- {
- if (subagentSessionId) {
- try {
- await sync.session.sync(subagentSessionId)
- route.navigate({ type: "session", sessionID: subagentSessionId })
- } catch (e) {
- console.error("Failed to sync subagent session:", e)
- toast.show({
- message: `Session not found`,
- variant: "error",
- })
- }
- }
- }}
- >
-
- {isActive() ? getSpinnerFrame() : isError() ? "✗" : "✓"}
-
-
- {description}
-
-
- )
- }}
-
-
- )
- }}
-
-
-
-
- 0 && todo().some((t) => t.status !== "completed")}>
-
- todo().length > 2 && setExpanded("todo", !expanded.todo)}
- >
- 2}>
- {expanded.todo ? "▼" : "▶"}
-
-
- Todo
-
-
-
- {(todo) => }
-
-
-
+ {/* Changed Files Section */}
0}>
- diff().length > 2 && setExpanded("diff", !expanded.diff)}
- >
- 2}>
- {expanded.diff ? "▼" : "▶"}
-
+ setExpanded("diff", !expanded.diff)}>
+ {expanded.diff ? "▼" : "▶"}
- Modified Files
+ Changed Files
+
+ ({diff().length} files)
+
-
+
{(item) => {
const file = createMemo(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts
index fa679529890..ef359e6f40e 100644
--- a/packages/opencode/src/cli/cmd/tui/spawn.ts
+++ b/packages/opencode/src/cli/cmd/tui/spawn.ts
@@ -3,31 +3,19 @@ import { Instance } from "@/project/instance"
import path from "path"
import { Server } from "@/server/server"
import { upgrade } from "@/cli/upgrade"
+import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
export const TuiSpawnCommand = cmd({
command: "spawn [project]",
builder: (yargs) =>
- yargs
- .positional("project", {
- type: "string",
- describe: "path to start opencode in",
- })
- .option("port", {
- type: "number",
- describe: "port to listen on",
- default: 0,
- })
- .option("hostname", {
- type: "string",
- describe: "hostname to listen on",
- default: "127.0.0.1",
- }),
+ withNetworkOptions(yargs).positional("project", {
+ type: "string",
+ describe: "path to start opencode in",
+ }),
handler: async (args) => {
upgrade()
- const server = Server.listen({
- port: args.port,
- hostname: "127.0.0.1",
- })
+ const opts = await resolveNetworkOptions(args)
+ const server = Server.listen(opts)
const bin = process.execPath
const cmd = []
let cwd = process.cwd()
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index dda313e19bc..5f07dc83b9e 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -6,6 +6,7 @@ import path from "path"
import { UI } from "@/cli/ui"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
+import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -15,7 +16,7 @@ export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
- yargs
+ withNetworkOptions(yargs)
.positional("project", {
type: "string",
describe: "path to start opencode in",
@@ -36,23 +37,12 @@ export const TuiThreadCommand = cmd({
describe: "session id to continue",
})
.option("prompt", {
- alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
- })
- .option("port", {
- type: "number",
- describe: "port to listen on",
- default: 0,
- })
- .option("hostname", {
- type: "string",
- describe: "hostname to listen on",
- default: "127.0.0.1",
}),
handler: async (args) => {
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
@@ -87,10 +77,8 @@ export const TuiThreadCommand = cmd({
process.on("unhandledRejection", (e) => {
Log.Default.error(e)
})
- const server = await client.call("server", {
- port: args.port,
- hostname: args.hostname,
- })
+ const opts = await resolveNetworkOptions(args)
+ const server = await client.call("server", opts)
const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 76f78f3faa8..3ffc45ae884 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -30,7 +30,7 @@ process.on("uncaughtException", (e) => {
let server: Bun.Server
export const rpc = {
- async server(input: { port: number; hostname: string }) {
+ async server(input: { port: number; hostname: string; mdns?: boolean }) {
if (server) await server.stop(true)
try {
server = Server.listen(input)
diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts
index 3d3036b1b07..fb32472d7ab 100644
--- a/packages/opencode/src/cli/cmd/web.ts
+++ b/packages/opencode/src/cli/cmd/web.ts
@@ -1,6 +1,7 @@
import { Server } from "../../server/server"
import { UI } from "../ui"
import { cmd } from "./cmd"
+import { withNetworkOptions, resolveNetworkOptions } from "../network"
import open from "open"
import { networkInterfaces } from "os"
@@ -28,32 +29,16 @@ function getNetworkIPs() {
export const WebCommand = cmd({
command: "web",
- builder: (yargs) =>
- yargs
- .option("port", {
- alias: ["p"],
- type: "number",
- describe: "port to listen on",
- default: 0,
- })
- .option("hostname", {
- type: "string",
- describe: "hostname to listen on",
- default: "127.0.0.1",
- }),
+ builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
handler: async (args) => {
- const hostname = args.hostname
- const port = args.port
- const server = Server.listen({
- port,
- hostname,
- })
+ const opts = await resolveNetworkOptions(args)
+ const server = Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
- if (hostname === "0.0.0.0") {
+ if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
const localhostUrl = `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
@@ -70,6 +55,10 @@ export const WebCommand = cmd({
}
}
+ if (opts.mdns) {
+ UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
+ }
+
// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
} else {
diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts
new file mode 100644
index 00000000000..397f2ba3e20
--- /dev/null
+++ b/packages/opencode/src/cli/network.ts
@@ -0,0 +1,43 @@
+import type { Argv, InferredOptionTypes } from "yargs"
+import { Config } from "../config/config"
+
+const options = {
+ port: {
+ type: "number" as const,
+ describe: "port to listen on",
+ default: 0,
+ },
+ hostname: {
+ type: "string" as const,
+ describe: "hostname to listen on",
+ default: "127.0.0.1",
+ },
+ mdns: {
+ type: "boolean" as const,
+ describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
+ default: false,
+ },
+}
+
+export type NetworkOptions = InferredOptionTypes
+
+export function withNetworkOptions(yargs: Argv) {
+ return yargs.options(options)
+}
+
+export async function resolveNetworkOptions(args: NetworkOptions) {
+ const config = await Config.global()
+ const portExplicitlySet = process.argv.includes("--port")
+ const hostnameExplicitlySet = process.argv.includes("--hostname")
+ const mdnsExplicitlySet = process.argv.includes("--mdns")
+
+ const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
+ const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
+ const hostname = hostnameExplicitlySet
+ ? args.hostname
+ : mdns && !config?.server?.hostname
+ ? "0.0.0.0"
+ : (config?.server?.hostname ?? args.hostname)
+
+ return { hostname, port, mdns }
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 77b47f95aec..7c10b7ca073 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -134,6 +134,14 @@ export namespace Config {
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
+ // Apply flag overrides for compaction settings
+ if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
+ result.compaction = { ...result.compaction, auto: false }
+ }
+ if (Flag.OPENCODE_DISABLE_PRUNE) {
+ result.compaction = { ...result.compaction, prune: false }
+ }
+
return {
config: result,
directories,
@@ -590,6 +598,17 @@ export namespace Config {
),
})
+ export const Server = z
+ .object({
+ port: z.number().int().positive().optional().describe("Port to listen on"),
+ hostname: z.string().optional().describe("Hostname to listen on"),
+ mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
+ })
+ .strict()
+ .meta({
+ ref: "ServerConfig",
+ })
+
export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig",
})
@@ -636,7 +655,9 @@ export namespace Config {
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
+ logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
+ server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
.optional()
@@ -782,6 +803,12 @@ export namespace Config {
})
.optional()
.describe("IDE integration settings"),
+ compaction: z
+ .object({
+ auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
+ prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
+ })
+ .optional(),
experimental: z
.object({
hook: z
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 7d6929fd84d..1194d7a0326 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -7,6 +7,7 @@ import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
+import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
@@ -273,6 +274,13 @@ export namespace File {
using _ = log.time("read", { file })
const project = Instance.project
const full = path.join(Instance.directory, file)
+
+ // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
+ // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
+ if (!Filesystem.contains(Instance.directory, full)) {
+ throw new Error(`Access denied: path escapes project directory`)
+ }
+
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
@@ -353,6 +361,13 @@ export namespace File {
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
+
+ // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
+ // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
+ if (!Filesystem.contains(Instance.directory, resolved)) {
+ throw new Error(`Access denied: path escapes project directory`)
+ }
+
const nodes: Node[] = []
for (const entry of await fs.promises
.readdir(resolved, {
diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts
index 954940f8db2..90c48b05c2a 100644
--- a/packages/opencode/src/format/formatter.ts
+++ b/packages/opencode/src/format/formatter.ts
@@ -313,3 +313,12 @@ export const gleam: Info = {
return Bun.which("gleam") !== null
},
}
+
+export const shfmt: Info = {
+ name: "shfmt",
+ command: ["shfmt", "-w", "$FILE"],
+ extensions: [".sh", ".bash"],
+ async enabled() {
+ return Bun.which("shfmt") !== null
+ },
+}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index ae80f567068..f3a5ac4eb23 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -87,6 +87,17 @@ export namespace Permission {
return state().pending
}
+ export function list() {
+ const { pending } = state()
+ const result: Info[] = []
+ for (const items of Object.values(pending)) {
+ for (const item of Object.values(items)) {
+ result.push(item.info)
+ }
+ }
+ return result.sort((a, b) => a.id.localeCompare(b.id))
+ }
+
export async function ask(input: {
type: Info["type"]
title: Info["title"]
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index d86fe90222d..407f7351b5b 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -214,7 +214,7 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 0.55
if (id.includes("claude")) return undefined
- if (id.includes("gemini-3-pro")) return 1.0
+ if (id.includes("gemini")) return 1.0
if (id.includes("glm-4.6")) return 1.0
if (id.includes("glm-4.7")) return 1.0
if (id.includes("minimax-m2")) return 1.0
@@ -232,12 +232,14 @@ export namespace ProviderTransform {
if (id.includes("m2.1")) return 0.9
return 0.95
}
+ if (id.includes("gemini")) return 0.95
return undefined
}
export function topK(model: Provider.Model) {
const id = model.id.toLowerCase()
if (id.includes("minimax-m2")) return 20
+ if (id.includes("gemini")) return 64
return undefined
}
diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts
new file mode 100644
index 00000000000..45e61d361ac
--- /dev/null
+++ b/packages/opencode/src/server/mdns.ts
@@ -0,0 +1,57 @@
+import { Log } from "@/util/log"
+import Bonjour from "bonjour-service"
+
+const log = Log.create({ service: "mdns" })
+
+export namespace MDNS {
+ let bonjour: Bonjour | undefined
+ let currentPort: number | undefined
+
+ export function publish(port: number, name = "opencode") {
+ if (currentPort === port) return
+ if (bonjour) unpublish()
+
+ try {
+ bonjour = new Bonjour()
+ const service = bonjour.publish({
+ name,
+ type: "http",
+ port,
+ txt: { path: "/" },
+ })
+
+ service.on("up", () => {
+ log.info("mDNS service published", { name, port })
+ })
+
+ service.on("error", (err) => {
+ log.error("mDNS service error", { error: err })
+ })
+
+ currentPort = port
+ } catch (err) {
+ log.error("mDNS publish failed", { error: err })
+ if (bonjour) {
+ try {
+ bonjour.destroy()
+ } catch {}
+ }
+ bonjour = undefined
+ currentPort = undefined
+ }
+ }
+
+ export function unpublish() {
+ if (bonjour) {
+ try {
+ bonjour.unpublishAll()
+ bonjour.destroy()
+ } catch (err) {
+ log.error("mDNS unpublish failed", { error: err })
+ }
+ bonjour = undefined
+ currentPort = undefined
+ log.info("mDNS service unpublished")
+ }
+ }
+}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 43575e0d9dc..936701d2217 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -48,10 +48,12 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket, serveStatic } from "hono/bun"
+import type { BunWebSocketData } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { AskQuestion } from "@/askquestion"
+import { MDNS } from "./mdns"
import fs from "fs"
import path from "path"
@@ -1135,6 +1137,8 @@ export namespace Server {
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
+ const session = await Session.get(sessionID)
+ await SessionRevert.cleanup(session)
const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
@@ -1651,6 +1655,28 @@ export namespace Server {
return c.json(true)
},
)
+ .get(
+ "/permission",
+ describeRoute({
+ summary: "List pending permissions",
+ description: "Get all pending permission requests across all sessions.",
+ operationId: "permission.list",
+ responses: {
+ 200: {
+ description: "List of pending permissions",
+ content: {
+ "application/json": {
+ schema: resolver(Permission.Info.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ const permissions = Permission.list()
+ return c.json(permissions)
+ },
+ )
.get(
"/command",
describeRoute({
@@ -2811,7 +2837,8 @@ export namespace Server {
)
export async function openapi() {
- const result = await generateSpecs(App(), {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = await generateSpecs(App() as any, {
documentation: {
info: {
title: "opencode",
@@ -2824,20 +2851,41 @@ export namespace Server {
return result
}
- export function listen(opts: { port: number; hostname: string }) {
+ export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: App().fetch,
websocket: websocket,
} as const
- if (opts.port === 0) {
+ const tryServe = (port: number) => {
try {
- return Bun.serve({ ...args, port: 4096 })
+ return Bun.serve({ ...args, port })
} catch {
- // port 4096 not available, fall through to use port 0
+ return undefined
}
}
- return Bun.serve({ ...args, port: opts.port })
+ const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
+ if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
+
+ const shouldPublishMDNS =
+ opts.mdns &&
+ server.port &&
+ opts.hostname !== "127.0.0.1" &&
+ opts.hostname !== "localhost" &&
+ opts.hostname !== "::1"
+ if (shouldPublishMDNS) {
+ MDNS.publish(server.port!)
+ } else if (opts.mdns) {
+ log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
+ }
+
+ const originalStop = server.stop.bind(server)
+ server.stop = async (closeActiveConnections?: boolean) => {
+ if (shouldPublishMDNS) MDNS.unpublish()
+ return originalStop(closeActiveConnections)
+ }
+
+ return server
}
}
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 1a50c36f3a4..fc90aa45f9b 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -7,13 +7,13 @@ import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
import { SessionPrompt } from "./prompt"
-import { Flag } from "../flag/flag"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
+import { Config } from "@/config/config"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
@@ -27,8 +27,9 @@ export namespace SessionCompaction {
),
}
- export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
- if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false
+ export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
+ const config = await Config.get()
+ if (config.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
@@ -46,7 +47,8 @@ export namespace SessionCompaction {
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) {
- if (Flag.OPENCODE_DISABLE_PRUNE) return
+ const config = await Config.get()
+ if (config.compaction?.prune === false) return
log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID })
let total = 0
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 0b1341a9966..f201d9b06dd 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -482,7 +482,7 @@ export namespace SessionPrompt {
if (
lastFinished &&
lastFinished.summary !== true &&
- SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })
+ (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }))
) {
await SessionCompaction.create({
sessionID,
diff --git a/packages/opencode/src/session/prompt/copilot-gpt-5.txt b/packages/opencode/src/session/prompt/copilot-gpt-5.txt
index 81594301944..f8e3e6b8c98 100644
--- a/packages/opencode/src/session/prompt/copilot-gpt-5.txt
+++ b/packages/opencode/src/session/prompt/copilot-gpt-5.txt
@@ -129,7 +129,7 @@ Tools can be disabled by the user. You may see tools used previously in the conv
Use proper Markdown formatting in your answers. When referring to a filename or symbol in the user's workspace, wrap it in backticks.
When sharing setup or run steps for the user to execute, render commands in fenced code blocks with an appropriate language tag (`bash`, `sh`, `powershell`, `python`, etc.). Keep one command per line; avoid prose-only representations of commands.
-Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multi-step tasks, maintain a lightweight checklist implicitly and weave progress into your narration.
+Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multistep tasks, maintain a lightweight checklist implicitly and weave progress into your narration.
For section headers in your response, use level-2 Markdown headings (`##`) for top-level sections and level-3 (`###`) for subsections. Choose titles dynamically to match the task and content. Do not hard-code fixed section names; create only the sections that make sense and only when they have non-empty content. Keep headings short and descriptive (e.g., "actions taken", "files changed", "how to run", "performance", "notes"), and order them naturally (actions > artifacts > how to run > performance > notes) when applicable. You may add a tasteful emoji to a heading when it improves scannability; keep it minimal and professional. Headings must start at the beginning of the line with `## ` or `### `, have a blank line before and after, and must not be inside lists, block quotes, or code fences.
When listing files created/edited, include a one-line purpose for each file when helpful. In performance sections, base any metrics on actual runs from this session; note the hardware/OS context and mark estimates clearly—never fabricate numbers. In "Try it" sections, keep commands copyable; comments starting with `#` are okay, but put each command on its own line.
If platform-specific acceleration applies, include an optional speed-up fenced block with commands. Close with a concise completion summary describing what changed and how it was verified (build/tests/linters), plus any follow-ups.
diff --git a/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt b/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt
index a5c2f267e07..28f1e629dbe 100644
--- a/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt
+++ b/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt
@@ -1,7 +1,7 @@
# Plan Mode - System Reminder
-Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
+Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
---
diff --git a/packages/opencode/src/session/prompt/polaris.txt b/packages/opencode/src/session/prompt/polaris.txt
deleted file mode 100644
index f90761890da..00000000000
--- a/packages/opencode/src/session/prompt/polaris.txt
+++ /dev/null
@@ -1,107 +0,0 @@
-You are OpenCode, the best coding agent on the planet.
-
-You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
-
-IMPORTANT: Do not guess arbitrary URLs. Only provide URLs you are confident are correct and directly helpful for programming (for example, well-known official documentation). Prefer URLs provided by the user in their messages or local files.
-
-If the user asks for help or wants to give feedback inform them of the following:
-- ctrl+p to list available actions
-- To give feedback, users should report the issue at
- https://github.com/sst/opencode
-
-When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs.
-
-When the user asks in second person (eg. "are you able...", "can you do..."), treat it as a request to help. Briefly confirm your capability and, when appropriate, immediately start performing the requested task or provide a concrete, useful answer instead of replying with only "yes" or "no".
-
-# Tone and style
-- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
-- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
-- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
-- Do not create new files unless necessary for achieving your goal or explicitly requested. Prefer editing an existing file when possible. This includes markdown files.
-
-# Professional objectivity
-Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
-
-# Task Management
-You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools frequently for multi-step or non-trivial tasks to give the user visibility into your progress.
-These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
-
-Prefer marking todos as completed soon after you finish each task, rather than delaying without reason.
-
-Examples:
-
-
-user: Run the build and fix any type errors
-assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
-- Run the build
-- Fix any type errors
-
-I'm now going to run the build using Bash.
-
-Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
-
-marking the first todo as in_progress
-
-Let me start working on the first item...
-
-The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
-..
-..
-
-In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
-
-
-user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
-assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
-Adding the following todos to the todo list:
-1. Research existing metrics tracking in the codebase
-2. Design the metrics collection system
-3. Implement core metrics tracking functionality
-4. Create export functionality for different formats
-
-Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
-
-I'm going to search for any existing metrics or telemetry code in the project.
-
-I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
-
-[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
-
-
-
-# Doing tasks
-The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
--
-- Use the TodoWrite tool to plan the task if required
-
-- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
-
-
-# Tool usage policy
-- When doing file search, prefer to use the Task tool in order to reduce context usage.
-- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
-
-- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
-- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
-- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
-- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
-- Generally use the Task tool for broader or multi-file exploration; direct reads and searches are fine for specific, simple queries.
-
-user: Where are errors from the client handled?
-assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly]
-
-
-user: What is the codebase structure?
-assistant: [Uses the Task tool]
-
-
-Prefer using the TodoWrite tool to plan and track tasks when there are multiple steps or files involved.
-
-# Code References
-
-When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
-
-
-user: Where are errors from the client handled?
-assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
-
diff --git a/packages/opencode/src/session/prompt/qwen.txt b/packages/opencode/src/session/prompt/qwen.txt
index a34fdb01a05..d88d9d063ba 100644
--- a/packages/opencode/src/session/prompt/qwen.txt
+++ b/packages/opencode/src/session/prompt/qwen.txt
@@ -84,7 +84,7 @@ The user will primarily request you perform software engineering tasks. This inc
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
-- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
+- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index 30094388168..429e696db3b 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -10,7 +10,6 @@ import os from "os"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
-import PROMPT_POLARIS from "./prompt/polaris.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
@@ -30,7 +29,6 @@ export namespace SystemPrompt {
return [PROMPT_BEAST]
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
- if (model.api.id.includes("polaris-alpha")) return [PROMPT_POLARIS]
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt
index a81deb62bf2..c31263c04eb 100644
--- a/packages/opencode/src/tool/bash.txt
+++ b/packages/opencode/src/tool/bash.txt
@@ -1,6 +1,6 @@
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
-All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory.
+All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
@@ -11,10 +11,10 @@ Before executing the command, please follow these steps:
- For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory
2. Command Execution:
- - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
+ - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt")
- Examples of proper quoting:
- - cd "/Users/name/My Documents" (correct)
- - cd /Users/name/My Documents (incorrect - will fail)
+ - mkdir "/Users/name/My Documents" (correct)
+ - mkdir /Users/name/My Documents (incorrect - will fail)
- python "/path/with spaces/script.py" (correct)
- python /path/with spaces/script.py (incorrect - will fail)
- After ensuring proper quoting, execute the command.
@@ -22,11 +22,11 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
+ - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
-
+
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)
- Content search: Use Grep (NOT grep or rg)
@@ -39,9 +39,9 @@ Usage notes:
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
+ - AVOID using `cd && `. Use the `workdir` parameter to change directories instead.
- pytest /foo/bar/tests
+ Use workdir="/foo/bar" with command: pytest tests
cd /foo/bar && pytest tests
@@ -53,7 +53,7 @@ Only create commits when requested by the user. If unclear, ask first. When the
Git Safety Protocol:
- NEVER update the git config
-- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
+- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
@@ -70,7 +70,7 @@ Git Safety Protocol:
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
- - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
+ - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
- Ensure it accurately reflects the changes and their purpose
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 07b2f4d10dc..129a3b811cd 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -187,8 +187,8 @@ export const EditTool = Tool.define("edit", {
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
- if (issues.length > 0) {
- const errors = issues.filter((item) => item.severity === 1)
+ const errors = issues.filter((item) => item.severity === 1)
+ if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt
index 6067ef27b9d..adf583695ae 100644
--- a/packages/opencode/src/tool/grep.txt
+++ b/packages/opencode/src/tool/grep.txt
@@ -5,4 +5,4 @@
- Returns file paths and line numbers with at least one match sorted by modification time
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
-- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
+- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
diff --git a/packages/opencode/src/tool/lsp-diagnostics.ts b/packages/opencode/src/tool/lsp-diagnostics.ts
deleted file mode 100644
index 18a6868b677..00000000000
--- a/packages/opencode/src/tool/lsp-diagnostics.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import z from "zod"
-import { Tool } from "./tool"
-import path from "path"
-import { LSP } from "../lsp"
-import DESCRIPTION from "./lsp-diagnostics.txt"
-import { Instance } from "../project/instance"
-
-export const LspDiagnosticTool = Tool.define("lsp_diagnostics", {
- description: DESCRIPTION,
- parameters: z.object({
- path: z.string().describe("The path to the file to get diagnostics."),
- }),
- execute: async (args) => {
- const normalized = path.isAbsolute(args.path) ? args.path : path.join(Instance.directory, args.path)
- await LSP.touchFile(normalized, true)
- const diagnostics = await LSP.diagnostics()
- const file = diagnostics[normalized]
- return {
- title: path.relative(Instance.worktree, normalized),
- metadata: {
- diagnostics,
- },
- output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found",
- }
- },
-})
diff --git a/packages/opencode/src/tool/lsp-diagnostics.txt b/packages/opencode/src/tool/lsp-diagnostics.txt
deleted file mode 100644
index 88a50f6347a..00000000000
--- a/packages/opencode/src/tool/lsp-diagnostics.txt
+++ /dev/null
@@ -1 +0,0 @@
-do not use
diff --git a/packages/opencode/src/tool/lsp-hover.ts b/packages/opencode/src/tool/lsp-hover.ts
deleted file mode 100644
index 7ef856cc567..00000000000
--- a/packages/opencode/src/tool/lsp-hover.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import z from "zod"
-import { Tool } from "./tool"
-import path from "path"
-import { LSP } from "../lsp"
-import DESCRIPTION from "./lsp-hover.txt"
-import { Instance } from "../project/instance"
-
-export const LspHoverTool = Tool.define("lsp_hover", {
- description: DESCRIPTION,
- parameters: z.object({
- file: z.string().describe("The path to the file to get diagnostics."),
- line: z.number().describe("The line number to get diagnostics."),
- character: z.number().describe("The character number to get diagnostics."),
- }),
- execute: async (args) => {
- const file = path.isAbsolute(args.file) ? args.file : path.join(Instance.directory, args.file)
- await LSP.touchFile(file, true)
- const result = await LSP.hover({
- ...args,
- file,
- })
-
- return {
- title: path.relative(Instance.worktree, file) + ":" + args.line + ":" + args.character,
- metadata: {
- result,
- },
- output: JSON.stringify(result, null, 2),
- }
- },
-})
diff --git a/packages/opencode/src/tool/lsp-hover.txt b/packages/opencode/src/tool/lsp-hover.txt
deleted file mode 100644
index 88a50f6347a..00000000000
--- a/packages/opencode/src/tool/lsp-hover.txt
+++ /dev/null
@@ -1 +0,0 @@
-do not use
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 68a4fcf54b2..712c1419a32 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -101,11 +101,11 @@ export const WriteTool = Tool.define("write", {
const normalizedFilepath = Filesystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
- if (issues.length === 0) continue
- const sorted = issues.toSorted((a, b) => (a.severity ?? 4) - (b.severity ?? 4))
- const limited = sorted.slice(0, MAX_DIAGNOSTICS_PER_FILE)
+ const errors = issues.filter((item) => item.severity === 1)
+ if (errors.length === 0) continue
+ const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
- issues.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${issues.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
+ errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
if (file === normalizedFilepath) {
output += `\nThis file has errors, please fix\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n`
continue
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 4afe16b4403..0bc1bb5616c 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -598,3 +598,97 @@ test("config tools.ask can enable ask in build", async () => {
},
})
})
+
+test("compaction config defaults to true when not specified", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ // When not specified, compaction should be undefined (defaults handled in usage)
+ expect(config.compaction).toBeUndefined()
+ },
+ })
+})
+
+test("compaction config can disable auto compaction", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ compaction: {
+ auto: false,
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.compaction?.auto).toBe(false)
+ expect(config.compaction?.prune).toBeUndefined()
+ },
+ })
+})
+
+test("compaction config can disable prune", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ compaction: {
+ prune: false,
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.compaction?.prune).toBe(false)
+ expect(config.compaction?.auto).toBeUndefined()
+ },
+ })
+})
+
+test("compaction config can disable both auto and prune", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ compaction: {
+ auto: false,
+ prune: false,
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.compaction?.auto).toBe(false)
+ expect(config.compaction?.prune).toBe(false)
+ },
+ })
+})
diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts
new file mode 100644
index 00000000000..c20c76a2e7f
--- /dev/null
+++ b/packages/opencode/test/file/path-traversal.test.ts
@@ -0,0 +1,115 @@
+import { test, expect, describe } from "bun:test"
+import path from "path"
+import { Filesystem } from "../../src/util/filesystem"
+import { File } from "../../src/file"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("Filesystem.contains", () => {
+ test("allows paths within project", () => {
+ expect(Filesystem.contains("/project", "/project/src")).toBe(true)
+ expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
+ expect(Filesystem.contains("/project", "/project")).toBe(true)
+ })
+
+ test("blocks ../ traversal", () => {
+ expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
+ expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
+ expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
+ })
+
+ test("blocks absolute paths outside project", () => {
+ expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
+ expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
+ expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
+ })
+
+ test("handles prefix collision edge cases", () => {
+ expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
+ expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
+ })
+})
+
+/*
+ * Integration tests for File.read() and File.list() path traversal protection.
+ *
+ * These tests verify the HTTP API code path is protected. The HTTP endpoints
+ * in server.ts (GET /file/content, GET /file) call File.read()/File.list()
+ * directly - they do NOT go through ReadTool or the agent permission layer.
+ *
+ * This is a SEPARATE code path from ReadTool, which has its own checks.
+ */
+describe("File.read path traversal protection", () => {
+ test("rejects ../ traversal attempting to read /etc/passwd", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "allowed.txt"), "allowed content")
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
+ },
+ })
+ })
+
+ test("rejects deeply nested traversal", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
+ "Access denied: path escapes project directory",
+ )
+ },
+ })
+ })
+
+ test("allows valid paths within project", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "valid.txt"), "valid content")
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("valid.txt")
+ expect(result.content).toBe("valid content")
+ },
+ })
+ })
+})
+
+describe("File.list path traversal protection", () => {
+ test("rejects ../ traversal attempting to list /etc", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
+ },
+ })
+ })
+
+ test("allows valid subdirectory listing", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "subdir", "file.txt"), "content")
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.list("subdir")
+ expect(Array.isArray(result)).toBe(true)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts
new file mode 100644
index 00000000000..de2b14573f4
--- /dev/null
+++ b/packages/opencode/test/session/revert-compact.test.ts
@@ -0,0 +1,285 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import path from "path"
+import { Session } from "../../src/session"
+import { SessionRevert } from "../../src/session/revert"
+import { SessionCompaction } from "../../src/session/compaction"
+import { MessageV2 } from "../../src/session/message-v2"
+import { Log } from "../../src/util/log"
+import { Instance } from "../../src/project/instance"
+import { Identifier } from "../../src/id/id"
+import { tmpdir } from "../fixture/fixture"
+
+const projectRoot = path.join(__dirname, "../..")
+Log.init({ print: false })
+
+describe("revert + compact workflow", () => {
+ test("should properly handle compact command after revert", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Create a session
+ const session = await Session.create({})
+ const sessionID = session.id
+
+ // Create a user message
+ const userMsg1 = await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID,
+ agent: "default",
+ model: {
+ providerID: "openai",
+ modelID: "gpt-4",
+ },
+ time: {
+ created: Date.now(),
+ },
+ })
+
+ // Add a text part to the user message
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: userMsg1.id,
+ sessionID,
+ type: "text",
+ text: "Hello, please help me",
+ })
+
+ // Create an assistant response message
+ const assistantMsg1: MessageV2.Assistant = {
+ id: Identifier.ascending("message"),
+ role: "assistant",
+ sessionID,
+ mode: "default",
+ agent: "default",
+ path: {
+ cwd: tmp.path,
+ root: tmp.path,
+ },
+ cost: 0,
+ tokens: {
+ output: 0,
+ input: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ modelID: "gpt-4",
+ providerID: "openai",
+ parentID: userMsg1.id,
+ time: {
+ created: Date.now(),
+ },
+ finish: "end_turn",
+ }
+ await Session.updateMessage(assistantMsg1)
+
+ // Add a text part to the assistant message
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: assistantMsg1.id,
+ sessionID,
+ type: "text",
+ text: "Sure, I'll help you!",
+ })
+
+ // Create another user message
+ const userMsg2 = await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID,
+ agent: "default",
+ model: {
+ providerID: "openai",
+ modelID: "gpt-4",
+ },
+ time: {
+ created: Date.now(),
+ },
+ })
+
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: userMsg2.id,
+ sessionID,
+ type: "text",
+ text: "What's the capital of France?",
+ })
+
+ // Create another assistant response
+ const assistantMsg2: MessageV2.Assistant = {
+ id: Identifier.ascending("message"),
+ role: "assistant",
+ sessionID,
+ mode: "default",
+ agent: "default",
+ path: {
+ cwd: tmp.path,
+ root: tmp.path,
+ },
+ cost: 0,
+ tokens: {
+ output: 0,
+ input: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ modelID: "gpt-4",
+ providerID: "openai",
+ parentID: userMsg2.id,
+ time: {
+ created: Date.now(),
+ },
+ finish: "end_turn",
+ }
+ await Session.updateMessage(assistantMsg2)
+
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: assistantMsg2.id,
+ sessionID,
+ type: "text",
+ text: "The capital of France is Paris.",
+ })
+
+ // Verify messages before revert
+ let messages = await Session.messages({ sessionID })
+ expect(messages.length).toBe(4) // 2 user + 2 assistant messages
+ const messageIds = messages.map((m) => m.info.id)
+ expect(messageIds).toContain(userMsg1.id)
+ expect(messageIds).toContain(userMsg2.id)
+ expect(messageIds).toContain(assistantMsg1.id)
+ expect(messageIds).toContain(assistantMsg2.id)
+
+ // Revert the last user message (userMsg2)
+ await SessionRevert.revert({
+ sessionID,
+ messageID: userMsg2.id,
+ })
+
+ // Check that revert state is set
+ let sessionInfo = await Session.get(sessionID)
+ expect(sessionInfo.revert).toBeDefined()
+ const revertMessageID = sessionInfo.revert?.messageID
+ expect(revertMessageID).toBeDefined()
+
+ // Messages should still be in the list (not removed yet, just marked for revert)
+ messages = await Session.messages({ sessionID })
+ expect(messages.length).toBe(4)
+
+ // Now clean up the revert state (this is what the compact endpoint should do)
+ await SessionRevert.cleanup(sessionInfo)
+
+ // After cleanup, the reverted messages (those after the revert point) should be removed
+ messages = await Session.messages({ sessionID })
+ const remainingIds = messages.map((m) => m.info.id)
+ // The revert point is somewhere in the message chain, so we should have fewer messages
+ expect(messages.length).toBeLessThan(4)
+ // userMsg2 and assistantMsg2 should be removed (they come after the revert point)
+ expect(remainingIds).not.toContain(userMsg2.id)
+ expect(remainingIds).not.toContain(assistantMsg2.id)
+
+ // Revert state should be cleared
+ sessionInfo = await Session.get(sessionID)
+ expect(sessionInfo.revert).toBeUndefined()
+
+ // Clean up
+ await Session.remove(sessionID)
+ },
+ })
+ })
+
+ test("should properly clean up revert state before creating compaction message", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Create a session
+ const session = await Session.create({})
+ const sessionID = session.id
+
+ // Create initial messages
+ const userMsg = await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID,
+ agent: "default",
+ model: {
+ providerID: "openai",
+ modelID: "gpt-4",
+ },
+ time: {
+ created: Date.now(),
+ },
+ })
+
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: userMsg.id,
+ sessionID,
+ type: "text",
+ text: "Hello",
+ })
+
+ const assistantMsg: MessageV2.Assistant = {
+ id: Identifier.ascending("message"),
+ role: "assistant",
+ sessionID,
+ mode: "default",
+ agent: "default",
+ path: {
+ cwd: tmp.path,
+ root: tmp.path,
+ },
+ cost: 0,
+ tokens: {
+ output: 0,
+ input: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ modelID: "gpt-4",
+ providerID: "openai",
+ parentID: userMsg.id,
+ time: {
+ created: Date.now(),
+ },
+ finish: "end_turn",
+ }
+ await Session.updateMessage(assistantMsg)
+
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: assistantMsg.id,
+ sessionID,
+ type: "text",
+ text: "Hi there!",
+ })
+
+ // Revert the user message
+ await SessionRevert.revert({
+ sessionID,
+ messageID: userMsg.id,
+ })
+
+ // Check that revert state is set
+ let sessionInfo = await Session.get(sessionID)
+ expect(sessionInfo.revert).toBeDefined()
+
+ // Simulate what the compact endpoint does: cleanup revert before creating compaction
+ await SessionRevert.cleanup(sessionInfo)
+
+ // Verify revert state is cleared
+ sessionInfo = await Session.get(sessionID)
+ expect(sessionInfo.revert).toBeUndefined()
+
+ // Verify messages are properly cleaned up
+ const messages = await Session.messages({ sessionID })
+ expect(messages.length).toBe(0) // All messages should be reverted
+
+ // Clean up
+ await Session.remove(sessionID)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 47a7aee2ae6..eb860d04fcc 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -13,6 +13,137 @@ const ctx = {
metadata: () => {},
}
+describe("tool.read external_directory permission", () => {
+ test("allows reading absolute path inside project directory", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "test.txt"), "hello world")
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ permission: {
+ external_directory: "deny",
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx)
+ expect(result.output).toContain("hello world")
+ },
+ })
+ })
+
+ test("allows reading file in subdirectory inside project directory", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ permission: {
+ external_directory: "deny",
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx)
+ expect(result.output).toContain("nested content")
+ },
+ })
+ })
+
+ test("denies reading absolute path outside project directory", async () => {
+ await using outerTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "secret.txt"), "secret data")
+ },
+ })
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ permission: {
+ external_directory: "deny",
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow(
+ "not in the current working directory",
+ )
+ },
+ })
+ })
+
+ test("denies reading relative path that traverses outside project directory", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ permission: {
+ external_directory: "deny",
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow(
+ "not in the current working directory",
+ )
+ },
+ })
+ })
+
+ test("allows reading outside project directory when external_directory is allow", async () => {
+ await using outerTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "external.txt"), "external content")
+ },
+ })
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ permission: {
+ external_directory: "allow",
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const read = await ReadTool.init()
+ const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx)
+ expect(result.output).toContain("external content")
+ },
+ })
+ })
+})
+
describe("tool.read env file blocking", () => {
test.each([
[".env", true],
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 94930fa446a..4d82f2a5fd8 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.0.203",
+ "version": "1.0.204",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index f1e0f77a750..ac6f8480269 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.0.203",
+ "version": "1.0.204",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 25206de8e84..c7a94f854cf 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1198,6 +1198,10 @@ export type Config = {
*/
theme?: string
keybinds?: KeybindsConfig
+ /**
+ * Log level
+ */
+ logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR"
/**
* TUI specific settings
*/
diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts
index a09e14ab2aa..174131ccfd5 100644
--- a/packages/sdk/js/src/server.ts
+++ b/packages/sdk/js/src/server.ts
@@ -28,7 +28,10 @@ export async function createOpencodeServer(options?: ServerOptions) {
options ?? {},
)
- const proc = spawn(`opencode`, [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`], {
+ const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
+ if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
+
+ const proc = spawn(`opencode`, args, {
signal: options.signal,
env: {
...process.env,
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 4e9d5bb2143..55cbe56810a 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -61,6 +61,7 @@ import type {
PartUpdateErrors,
PartUpdateResponses,
PathGetResponses,
+ PermissionListResponses,
PermissionRespondErrors,
PermissionRespondResponses,
ProjectBrowseResponses,
@@ -1687,6 +1688,25 @@ export class Permission extends HeyApiClient {
},
})
}
+
+ /**
+ * List pending permissions
+ *
+ * Get all pending permission requests across all sessions.
+ */
+ public list(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get({
+ url: "/permission",
+ ...options,
+ ...params,
+ })
+ }
}
export class Askquestion extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 4f29267efb5..b3bd564ea82 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1284,6 +1284,29 @@ export type KeybindsConfig = {
tips_toggle?: string
}
+/**
+ * Log level
+ */
+export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"
+
+/**
+ * Server configuration for opencode serve and web commands
+ */
+export type ServerConfig = {
+ /**
+ * Port to listen on
+ */
+ port?: number
+ /**
+ * Hostname to listen on
+ */
+ hostname?: string
+ /**
+ * Enable mDNS service discovery
+ */
+ mdns?: boolean
+}
+
export type AgentConfig = {
model?: string
temperature?: number
@@ -1521,6 +1544,7 @@ export type Config = {
*/
theme?: string
keybinds?: KeybindsConfig
+ logLevel?: LogLevel
/**
* TUI specific settings
*/
@@ -1547,6 +1571,7 @@ export type Config = {
*/
density?: "auto" | "comfortable" | "compact"
}
+ server?: ServerConfig
/**
* Command configuration, see https://opencode.ai/docs/commands
*/
@@ -1714,6 +1739,16 @@ export type Config = {
*/
auth_header_name?: string
}
+ compaction?: {
+ /**
+ * Enable automatic compaction when context is full (default: true)
+ */
+ auto?: boolean
+ /**
+ * Enable pruning of old tool outputs (default: true)
+ */
+ prune?: boolean
+ }
experimental?: {
hook?: {
file_edited?: {
@@ -3648,6 +3683,24 @@ export type AskquestionCancelResponses = {
export type AskquestionCancelResponse = AskquestionCancelResponses[keyof AskquestionCancelResponses]
+export type PermissionListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/permission"
+}
+
+export type PermissionListResponses = {
+ /**
+ * List of pending permissions
+ */
+ 200: Array
+}
+
+export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
+
export type CommandListData = {
body?: never
path?: never
diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts
index a09e14ab2aa..174131ccfd5 100644
--- a/packages/sdk/js/src/v2/server.ts
+++ b/packages/sdk/js/src/v2/server.ts
@@ -28,7 +28,10 @@ export async function createOpencodeServer(options?: ServerOptions) {
options ?? {},
)
- const proc = spawn(`opencode`, [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`], {
+ const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
+ if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
+
+ const proc = spawn(`opencode`, args, {
signal: options.signal,
env: {
...process.env,
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 96ba0720c73..3903566b91e 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -2879,6 +2879,43 @@
]
}
},
+ "/permission": {
+ "get": {
+ "operationId": "permission.list",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "List pending permissions",
+ "description": "Get all pending permission requests across all sessions.",
+ "responses": {
+ "200": {
+ "description": "List of pending permissions",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Permission"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})"
+ }
+ ]
+ }
+ },
"/command": {
"get": {
"operationId": "command.list",
@@ -7687,6 +7724,32 @@
},
"additionalProperties": false
},
+ "LogLevel": {
+ "description": "Log level",
+ "type": "string",
+ "enum": ["DEBUG", "INFO", "WARN", "ERROR"]
+ },
+ "ServerConfig": {
+ "description": "Server configuration for opencode serve and web commands",
+ "type": "object",
+ "properties": {
+ "port": {
+ "description": "Port to listen on",
+ "type": "integer",
+ "exclusiveMinimum": 0,
+ "maximum": 9007199254740991
+ },
+ "hostname": {
+ "description": "Hostname to listen on",
+ "type": "string"
+ },
+ "mdns": {
+ "description": "Enable mDNS service discovery",
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+ },
"AgentConfig": {
"type": "object",
"properties": {
@@ -8143,6 +8206,9 @@
"keybinds": {
"$ref": "#/components/schemas/KeybindsConfig"
},
+ "logLevel": {
+ "$ref": "#/components/schemas/LogLevel"
+ },
"tui": {
"description": "TUI specific settings",
"type": "object",
@@ -8170,6 +8236,9 @@
}
}
},
+ "server": {
+ "$ref": "#/components/schemas/ServerConfig"
+ },
"command": {
"description": "Command configuration, see https://opencode.ai/docs/commands",
"type": "object",
@@ -8534,6 +8603,19 @@
}
}
},
+ "compaction": {
+ "type": "object",
+ "properties": {
+ "auto": {
+ "description": "Enable automatic compaction when context is full (default: true)",
+ "type": "boolean"
+ },
+ "prune": {
+ "description": "Enable pruning of old tool outputs (default: true)",
+ "type": "boolean"
+ }
+ }
+ },
"experimental": {
"type": "object",
"properties": {
diff --git a/packages/slack/package.json b/packages/slack/package.json
index 4c2f8eb7356..98cb0d7e7d6 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.0.203",
+ "version": "1.0.204",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 0e7da54bdcb..bb6adb0fb76 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.0.203",
+ "version": "1.0.204",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 28320eeb3e9..67720955dcb 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -1,4 +1,4 @@
-import { For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
@@ -24,11 +24,18 @@ export interface BasicToolProps {
children?: JSX.Element
hideDetails?: boolean
defaultOpen?: boolean
+ forceOpen?: boolean
}
export function BasicTool(props: BasicToolProps) {
+ const [open, setOpen] = createSignal(props.defaultOpen ?? false)
+
+ createEffect(() => {
+ if (props.forceOpen) setOpen(true)
+ })
+
return (
-
+
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 3f139065a66..c56f477881c 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -18,6 +18,7 @@ const icons = {
console: `
`,
expand: `
`,
collapse: `
`,
+ code: `
`,
"code-lines": `
`,
"circle-ban-sign": `
`,
"edit-small-2": `
`,
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 6daf1a8b513..a8a9e6a31ed 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -361,3 +361,98 @@
overflow: hidden;
}
}
+
+[data-component="tool-part-wrapper"] {
+ width: 100%;
+
+ &[data-permission="true"] {
+ position: sticky;
+ top: var(--sticky-header-height, 80px);
+ bottom: 0px;
+ z-index: 10;
+ border-radius: 6px;
+ border: none;
+ box-shadow: var(--shadow-xs-border-base);
+ background-color: var(--surface-raised-base);
+ overflow: visible;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: -1.5px;
+ border-radius: 7.5px;
+ border: 1.5px solid transparent;
+ background:
+ linear-gradient(var(--background-base) 0 0) padding-box,
+ conic-gradient(
+ from var(--border-angle),
+ transparent 0deg,
+ transparent 270deg,
+ var(--border-warning-strong, var(--border-warning-selected)) 300deg,
+ var(--border-warning-base) 360deg
+ )
+ border-box;
+ animation: chase-border 1.5s linear infinite;
+ pointer-events: none;
+ z-index: -1;
+ }
+
+ & > *:first-child {
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ overflow: hidden;
+ }
+
+ & > *:last-child {
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ overflow: hidden;
+ }
+
+ [data-component="collapsible"] {
+ border: none;
+ }
+
+ [data-component="card"] {
+ border: none;
+ }
+ }
+}
+
+@property --border-angle {
+ syntax: "
";
+ initial-value: 0deg;
+ inherits: false;
+}
+
+@keyframes chase-border {
+ from {
+ --border-angle: 0deg;
+ }
+ to {
+ --border-angle: 360deg;
+ }
+}
+
+[data-component="permission-prompt"] {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 12px;
+ background-color: var(--surface-raised-strong);
+ border-radius: 0 0 6px 6px;
+
+ [data-slot="permission-message"] {
+ display: none;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ }
+
+ [data-slot="permission-actions"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 1424041e8c0..0a1518b796e 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,4 +1,4 @@
-import { Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
+import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
AssistantMessage,
@@ -16,6 +16,7 @@ import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
+import { Button } from "./button"
import { Card } from "./card"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
@@ -188,11 +189,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
-function getToolPartInfo(part: ToolPart): ToolInfo {
- const input = part.state.input || {}
- return getToolInfo(part.tool, input)
-}
-
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -334,6 +330,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
+ forceOpen?: boolean
}
export type ToolComponent = Component
@@ -361,11 +358,35 @@ export const ToolRegistry = {
}
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ const data = useData()
const part = props.part as ToolPart
+
+ const permission = createMemo(() => {
+ const sessionID = props.message.sessionID
+ const permissions = data.store.permission?.[sessionID] ?? []
+ return permissions.find((p) => p.callID === part.callID)
+ })
+
+ const [forceOpen, setForceOpen] = createSignal(false)
+ createEffect(() => {
+ if (permission()) setForceOpen(true)
+ })
+
+ const respond = (response: "once" | "always" | "reject") => {
+ const perm = permission()
+ if (!perm || !data.respondToPermission) return
+ data.respondToPermission({
+ sessionID: perm.sessionID,
+ permissionID: perm.id,
+ response,
+ })
+ }
+
const component = createMemo(() => {
const render = ToolRegistry.render(part.tool) ?? GenericTool
- const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {})
- const input = part.state.status === "completed" ? part.state.input : {}
+ // @ts-expect-error
+ const metadata = part.state?.metadata ?? {}
+ const input = part.state?.input ?? {}
return (
@@ -399,9 +420,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
input={input}
tool={part.tool}
metadata={metadata}
- output={part.state.status === "completed" ? part.state.output : undefined}
+ // @ts-expect-error
+ output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
+ forceOpen={forceOpen()}
defaultOpen={props.defaultOpen}
/>
@@ -409,7 +432,29 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
})
- return {component()}
+ return (
+
+
{component()}
+
+ {(perm) => (
+
+
{perm().title}
+
+
+
+
+
+
+ )}
+
+
+ )
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
@@ -564,6 +609,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "task",
render(props) {
+ const data = useData()
const summary = () =>
(props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[]
@@ -571,35 +617,141 @@ ToolRegistry.register({
working: () => true,
})
+ const childSessionId = () => props.metadata.sessionId as string | undefined
+
+ const childPermission = createMemo(() => {
+ const sessionId = childSessionId()
+ if (!sessionId) return undefined
+ const permissions = data.store.permission?.[sessionId] ?? []
+ return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0]
+ })
+
+ const childToolPart = createMemo(() => {
+ const perm = childPermission()
+ if (!perm) return undefined
+ const sessionId = childSessionId()
+ if (!sessionId) return undefined
+ // Find the tool part that matches the permission's callID
+ const messages = data.store.message[sessionId] ?? []
+ for (const msg of messages) {
+ const parts = data.store.part[msg.id] ?? []
+ for (const part of parts) {
+ if (part.type === "tool" && (part as ToolPart).callID === perm.callID) {
+ return { part: part as ToolPart, message: msg }
+ }
+ }
+ }
+ return undefined
+ })
+
+ const respond = (response: "once" | "always" | "reject") => {
+ const perm = childPermission()
+ if (!perm || !data.respondToPermission) return
+ data.respondToPermission({
+ sessionID: perm.sessionID,
+ permissionID: perm.id,
+ response,
+ })
+ }
+
+ const renderChildToolPart = () => {
+ const toolData = childToolPart()
+ if (!toolData) return null
+ const { part } = toolData
+ const render = ToolRegistry.render(part.tool) ?? GenericTool
+ // @ts-expect-error
+ const metadata = part.state?.metadata ?? {}
+ const input = part.state?.input ?? {}
+ return (
+
+ )
+ }
+
return (
-
-
-
-
- {(item) => {
- const info = getToolInfo(item.tool)
- return (
-
-
-
{info.title}
-
- {item.state.title}
-
+
+
+
+ {(perm) => (
+ <>
+
+ }
+ >
+ {renderChildToolPart()}
+
+
+
{perm().title}
+
+
+
+
- )
+
+ >
+ )}
+
+
+
-
-
-
+ >
+
+
+
+ {(item) => {
+ const info = getToolInfo(item.tool)
+ return (
+
+
+ {info.title}
+
+ {item.state.title}
+
+
+ )
+ }}
+
+
+
+
+
+
+
)
},
})
@@ -618,7 +770,7 @@ ToolRegistry.register({
>
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 404fcffef3e..86f7b7fe38c 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -385,4 +385,12 @@
[data-slot="session-turn-markdown"] td:first-child {
font-family: monospace;
}
+
+ [data-slot="session-turn-permission-parts"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index a0368b0d492..ce4845a71c0 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -151,6 +151,22 @@ export function SessionTurn(
return false
})
+ const permissionParts = createMemo(() => {
+ const result: { part: ToolPart; message: AssistantMessage }[] = []
+ const permissions = data.store.permission?.[props.sessionID] ?? []
+ if (!permissions.length) return result
+
+ for (const m of assistantMessages()) {
+ const msgParts = data.store.part[m.id] ?? []
+ for (const p of msgParts) {
+ if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) {
+ result.push({ part: p as ToolPart, message: m })
+ }
+ }
+ }
+ return result
+ })
+
const shellModePart = createMemo(() => {
const p = parts()
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
@@ -469,6 +485,13 @@ export function SessionTurn(
+ 0}>
+
+
+ {({ part, message }) => }
+
+
+
{/* Summary */}
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx
index c1a29cd04dc..7e90e9f2f32 100644
--- a/packages/ui/src/components/toast.tsx
+++ b/packages/ui/src/components/toast.tsx
@@ -92,6 +92,7 @@ export type ToastVariant = "default" | "success" | "error" | "loading"
export interface ToastAction {
label: string
onClick: "dismiss" | (() => void)
+ dismissAfter?: boolean
}
export interface ToastOptions {
@@ -128,7 +129,14 @@ export function showToast(options: ToastOptions | string) {
{opts.actions!.map((action) => (
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index f532534188c..3292ba579f0 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -1,4 +1,4 @@
-import type { Message, Session, Part, FileDiff, SessionStatus } from "@opencode-ai/sdk/v2"
+import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -13,6 +13,9 @@ type Data = {
session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult
[]
}
+ permission?: {
+ [sessionID: string]: Permission[]
+ }
message: {
[sessionID: string]: Message[]
}
@@ -21,9 +24,15 @@ type Data = {
}
}
+export type PermissionRespondFn = (input: {
+ sessionID: string
+ permissionID: string
+ response: "once" | "always" | "reject"
+}) => void
+
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
- init: (props: { data: Data; directory: string }) => {
+ init: (props: { data: Data; directory: string; onPermissionRespond?: PermissionRespondFn }) => {
return {
get store() {
return props.data
@@ -31,6 +40,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
get directory() {
return props.directory
},
+ respondToPermission: props.onPermissionRespond,
}
},
})
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index 56be9ee4789..8e1a6aad8e5 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -33,10 +33,6 @@ function init() {
},
close() {
active()?.onClose?.()
- if (!active()?.onClose) {
- const promptInput = document.querySelector("[data-component=prompt-input]") as HTMLElement
- promptInput?.focus()
- }
setActive(undefined)
},
show(element: DialogElement, owner: Owner, onClose?: () => void) {
diff --git a/packages/util/package.json b/packages/util/package.json
index c5df6f176bc..f558fdc01c0 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
- "version": "1.0.203",
+ "version": "1.0.204",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs
index 7ecf2bfd9d6..dba43d02fa3 100644
--- a/packages/web/astro.config.mjs
+++ b/packages/web/astro.config.mjs
@@ -36,7 +36,7 @@ export default defineConfig({
expressiveCode: { themes: ["github-light", "github-dark"] },
social: [
{ icon: "github", label: "GitHub", href: config.github },
- { icon: "discord", label: "Dscord", href: config.discord },
+ { icon: "discord", label: "Discord", href: config.discord },
],
editLink: {
baseUrl: `${config.github}/edit/dev/packages/web/`,
diff --git a/packages/web/package.json b/packages/web/package.json
index 2fb471239b7..866eaab394a 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
- "version": "1.0.203",
+ "version": "1.0.204",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index e4e40ac7a4c..4a826e5b3ff 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/packages/web/src/content/docs/cli.mdx
@@ -335,10 +335,11 @@ This starts an HTTP server that provides API access to opencode functionality wi
#### Flags
-| Flag | Short | Description |
-| ------------ | ----- | --------------------- |
-| `--port` | `-p` | Port to listen on |
-| `--hostname` | | Hostname to listen on |
+| Flag | Description |
+| ------------ | --------------------- |
+| `--port` | Port to listen on |
+| `--hostname` | Hostname to listen on |
+| `--mdns` | Enable mDNS discovery |
---
@@ -428,10 +429,11 @@ This starts an HTTP server and opens a web browser to access OpenCode through a
#### Flags
-| Flag | Short | Description |
-| ------------ | ----- | --------------------- |
-| `--port` | `-p` | Port to listen on |
-| `--hostname` | | Hostname to listen on |
+| Flag | Description |
+| ------------ | --------------------- |
+| `--port` | Port to listen on |
+| `--hostname` | Hostname to listen on |
+| `--mdns` | Enable mDNS discovery |
---
diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx
index ebaff36bb15..d7f8031782c 100644
--- a/packages/web/src/content/docs/config.mdx
+++ b/packages/web/src/content/docs/config.mdx
@@ -120,6 +120,31 @@ Available options:
---
+### Server
+
+You can configure server settings for the `opencode serve` and `opencode web` commands through the `server` option.
+
+```json title="opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "server": {
+ "port": 4096,
+ "hostname": "0.0.0.0",
+ "mdns": true
+ }
+}
+```
+
+Available options:
+
+- `port` - Port to listen on.
+- `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`.
+- `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server.
+
+[Learn more about the server here](/docs/server).
+
+---
+
### Tools
You can manage the tools an LLM can use through the `tools` option.
diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx
index c2c01836bb3..2c0687b8ea5 100644
--- a/packages/web/src/content/docs/formatters.mdx
+++ b/packages/web/src/content/docs/formatters.mdx
@@ -11,26 +11,27 @@ OpenCode automatically formats files after they are written or edited using lang
OpenCode comes with several built-in formatters for popular languages and frameworks. Below is a list of the formatters, supported file extensions, and commands or config options it needs.
-| Formatter | Extensions | Requirements |
-| -------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
-| gofmt | .go | `gofmt` command available |
-| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available |
-| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` |
-| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file |
-| zig | .zig, .zon | `zig` command available |
-| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file |
-| ktlint | .kt, .kts | `ktlint` command available |
-| ruff | .py, .pyi | `ruff` command available with config |
-| uv | .py, .pyi | `uv` command available |
-| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
-| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |
-| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available |
-| air | .R | `air` command available |
-| dart | .dart | `dart` command available |
-| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file |
-| terraform | .tf, .tfvars | `terraform` command available |
-| gleam | .gleam | `gleam` command available |
-| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experiental env variable flag](/docs/cli/#experimental) |
+| Formatter | Extensions | Requirements |
+| -------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| gofmt | .go | `gofmt` command available |
+| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available |
+| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` |
+| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file |
+| zig | .zig, .zon | `zig` command available |
+| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file |
+| ktlint | .kt, .kts | `ktlint` command available |
+| ruff | .py, .pyi | `ruff` command available with config |
+| uv | .py, .pyi | `uv` command available |
+| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
+| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |
+| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available |
+| air | .R | `air` command available |
+| dart | .dart | `dart` command available |
+| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file |
+| terraform | .tf, .tfvars | `terraform` command available |
+| gleam | .gleam | `gleam` command available |
+| shfmt | .sh, .bash | `shfmt` command available |
+| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) |
So if your project has `prettier` in your `package.json`, OpenCode will automatically use it.
diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx
index 25c3ce927a1..63c5d855b9c 100644
--- a/packages/web/src/content/docs/github.mdx
+++ b/packages/web/src/content/docs/github.mdx
@@ -104,12 +104,14 @@ Or you can set it up manually.
OpenCode can be triggered by the following GitHub events:
-| Event Type | Triggered By | Details |
-| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads the issue/PR context and can create branches, open PRs, or reply with explanations. |
-| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context for precise responses. |
-| `schedule` | Cron-based schedule | Run OpenCode on a schedule using the `prompt` input. Useful for automated code reviews, reports, or maintenance tasks. OpenCode can create issues or PRs as needed. |
-| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews without needing to leave a comment. |
+| Event Type | Triggered By | Details |
+| ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads context and can create branches, open PRs, or reply. |
+| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context. |
+| `issues` | Issue opened or edited | Automatically trigger OpenCode when issues are created or modified. Requires `prompt` input. |
+| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews. |
+| `schedule` | Cron-based schedule | Run OpenCode on a schedule. Requires `prompt` input. Output goes to logs and PRs (no issue to comment on). |
+| `workflow_dispatch` | Manual trigger from GitHub UI | Trigger OpenCode on demand via Actions tab. Requires `prompt` input. Output goes to logs and PRs. |
### Schedule Example
@@ -145,9 +147,7 @@ jobs:
If you find issues worth addressing, open an issue to track them.
```
-For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from.
-
-> **Note:** Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs during a scheduled run.
+For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs.
---
@@ -188,6 +188,59 @@ For `pull_request` events, if no `prompt` is provided, OpenCode defaults to revi
---
+### Issues Triage Example
+
+Automatically triage new issues. This example filters to accounts older than 30 days to reduce spam:
+
+```yaml title=".github/workflows/opencode-triage.yml"
+name: Issue Triage
+
+on:
+ issues:
+ types: [opened]
+
+jobs:
+ triage:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: write
+ pull-requests: write
+ issues: write
+ steps:
+ - name: Check account age
+ id: check
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const user = await github.rest.users.getByUsername({
+ username: context.payload.issue.user.login
+ });
+ const created = new Date(user.data.created_at);
+ const days = (Date.now() - created) / (1000 * 60 * 60 * 24);
+ return days >= 30;
+ result-encoding: string
+
+ - uses: actions/checkout@v4
+ if: steps.check.outputs.result == 'true'
+
+ - uses: sst/opencode/github@latest
+ if: steps.check.outputs.result == 'true'
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ with:
+ model: anthropic/claude-sonnet-4-20250514
+ prompt: |
+ Review this issue. If there's a clear fix or relevant docs:
+ - Provide documentation links
+ - Add error handling guidance for code examples
+ Otherwise, do not comment.
+```
+
+For `issues` events, the `prompt` input is **required** since there's no comment to extract instructions from.
+
+---
+
## Custom prompts
Override the default prompt to customize OpenCode's behavior for your workflow.
diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx
index 427d8f505ff..c63917f792e 100644
--- a/packages/web/src/content/docs/server.mdx
+++ b/packages/web/src/content/docs/server.mdx
@@ -18,10 +18,11 @@ opencode serve [--port ] [--hostname ]
#### Options
-| Flag | Short | Description | Default |
-| ------------ | ----- | --------------------- | ----------- |
-| `--port` | `-p` | Port to listen on | `4096` |
-| `--hostname` | `-h` | Hostname to listen on | `127.0.0.1` |
+| Flag | Description | Default |
+| ------------ | --------------------- | ----------- |
+| `--port` | Port to listen on | `4096` |
+| `--hostname` | Hostname to listen on | `127.0.0.1` |
+| `--mdns` | Enable mDNS discovery | `false` |
---
diff --git a/script/sync-zed.ts b/script/sync-zed.ts
index b4a417ad8b9..3ac9ee83a7e 100755
--- a/script/sync-zed.ts
+++ b/script/sync-zed.ts
@@ -107,7 +107,7 @@ async function main() {
console.log(`📬 Creating pull request...`)
const prUrl =
- await $`gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text()
+ await $`GH_TOKEN=${token} gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text()
console.log(`✅ Pull request created: ${prUrl}`)
console.log(`🎉 Done!`)
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 1b4cf99f985..5d15e76c2eb 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "1.0.203",
+ "version": "1.0.204",
"publisher": "sst-dev",
"repository": {
"type": "git",