diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 49d75a95ecc7..9a1fba5d5c49 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
- await client.session.prompt({
+ await client.session.promptAsync({
sessionID: session.id,
agent,
model,
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index 8b77edf3aa83..c6e60d3edebd 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -19,8 +19,7 @@ function openSessionContext(args: {
tabs: ReturnType
["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
- args.layout.fileTree.open()
- args.layout.fileTree.setTab("all")
+ if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
args.tabs.open("context")
args.tabs.setActive("context")
}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 274398ee0889..f81a2ec44031 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -311,12 +311,14 @@ export function SessionHeader() {
platform,
})
- const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
+ const leftMount = createMemo(
+ () => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
+ )
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
-
+
{(mount) => (
-
+
-
+
-
+
{
{ value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
+ { value: "geist-mono", label: "font.option.geistMono" },
] as const
const fontOptionsList = [...fontOptions]
@@ -306,39 +307,66 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
- settings.sounds.agent(),
- (id) => settings.sounds.setAgent(id),
- )}
- />
+
+
+ settings.sounds.setAgentEnabled(checked)}
+ />
+
+
settings.sounds.agent(),
+ (id) => settings.sounds.setAgent(id),
+ )}
+ />
+
- settings.sounds.permissions(),
- (id) => settings.sounds.setPermissions(id),
- )}
- />
+
+
+ settings.sounds.setPermissionsEnabled(checked)}
+ />
+
+
settings.sounds.permissions(),
+ (id) => settings.sounds.setPermissions(id),
+ )}
+ />
+
- settings.sounds.errors(),
- (id) => settings.sounds.setErrors(id),
- )}
- />
+
+
+ settings.sounds.setErrorsEnabled(checked)}
+ />
+
+
settings.sounds.errors(),
+ (id) => settings.sounds.setErrors(id),
+ )}
+ />
+
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index f6bb0b48a6af..14413dfda677 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
+import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -155,11 +156,16 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
+ let fitFrame: number | undefined
+ let sizeTimer: ReturnType
| undefined
+ let pendingSize: { cols: number; rows: number } | undefined
+ let lastSize: { cols: number; rows: number } | undefined
let disposed = false
const cleanups: VoidFunction[] = []
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
+ let output: ReturnType | undefined
const cleanup = () => {
if (!cleanups.length) return
@@ -207,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal(getTerminalColors())
+ const scheduleFit = () => {
+ if (disposed) return
+ if (!fitAddon) return
+ if (fitFrame !== undefined) return
+
+ fitFrame = requestAnimationFrame(() => {
+ fitFrame = undefined
+ if (disposed) return
+ fitAddon.fit()
+ })
+ }
+
+ const scheduleSize = (cols: number, rows: number) => {
+ if (disposed) return
+ if (lastSize?.cols === cols && lastSize?.rows === rows) return
+
+ pendingSize = { cols, rows }
+
+ if (!lastSize) {
+ lastSize = pendingSize
+ void pushSize(cols, rows)
+ return
+ }
+
+ if (sizeTimer !== undefined) return
+ sizeTimer = setTimeout(() => {
+ sizeTimer = undefined
+ const next = pendingSize
+ if (!next) return
+ pendingSize = undefined
+ if (disposed) return
+ if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
+ lastSize = next
+ void pushSize(next.cols, next.rows)
+ }, 100)
+ }
+
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
@@ -218,6 +261,16 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
+ scheduleFit()
+ })
+
+ let zoom = platform.webviewZoom?.()
+ createEffect(() => {
+ const next = platform.webviewZoom?.()
+ if (next === undefined) return
+ if (next === zoom) return
+ zoom = next
+ scheduleFit()
})
const focusTerminal = () => {
@@ -261,25 +314,6 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
- const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
- url.searchParams.set("directory", sdk.directory)
- url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
- if (window.__OPENCODE__?.serverPassword) {
- url.username = "opencode"
- url.password = window.__OPENCODE__?.serverPassword
- }
- const socket = new WebSocket(url)
- socket.binaryType = "arraybuffer"
- cleanups.push(() => {
- if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
- })
- if (disposed) {
- cleanup()
- return
- }
- ws = socket
-
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -300,7 +334,7 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
- convertEol: true,
+ convertEol: false,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@@ -312,6 +346,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
+ output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -341,9 +376,28 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
+ if (typeof document !== "undefined" && document.fonts) {
+ document.fonts.ready.then(scheduleFit)
+ }
+
+ const onResize = t.onResize((size) => {
+ scheduleSize(size.cols, size.rows)
+ })
+ cleanups.push(() => disposeIfDisposable(onResize))
+ const onData = t.onData((data) => {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(data)
+ })
+ cleanups.push(() => disposeIfDisposable(onData))
+ const onKey = t.onKey((key) => {
+ if (key.key == "Enter") {
+ props.onSubmit?.()
+ }
+ })
+ cleanups.push(() => disposeIfDisposable(onKey))
+
const startResize = () => {
fit.observeResize()
- handleResize = () => fit.fit()
+ handleResize = scheduleFit
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
@@ -351,11 +405,13 @@ export const Terminal = (props: TerminalProps) => {
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
+ scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
+ scheduleSize(t.cols, t.rows)
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
@@ -364,35 +420,38 @@ export const Terminal = (props: TerminalProps) => {
startResize()
}
- const onResize = t.onResize(async (size) => {
- if (socket.readyState === WebSocket.OPEN) {
- await pushSize(size.cols, size.rows)
- }
- })
- cleanups.push(() => disposeIfDisposable(onResize))
- const onData = t.onData((data) => {
- if (socket.readyState === WebSocket.OPEN) {
- socket.send(data)
- }
- })
- cleanups.push(() => disposeIfDisposable(onData))
- const onKey = t.onKey((key) => {
- if (key.key == "Enter") {
- props.onSubmit?.()
- }
- })
- cleanups.push(() => disposeIfDisposable(onKey))
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
+ const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
+ url.searchParams.set("directory", sdk.directory)
+ url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+ if (window.__OPENCODE__?.serverPassword) {
+ url.username = "opencode"
+ url.password = window.__OPENCODE__?.serverPassword
+ }
+ const socket = new WebSocket(url)
+ socket.binaryType = "arraybuffer"
+ ws = socket
+ cleanups.push(() => {
+ if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
+ })
+ if (disposed) {
+ cleanup()
+ return
+ }
+
const handleOpen = () => {
local.onConnect?.()
- void pushSize(t.cols, t.rows)
+ scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
+ if (socket.readyState === WebSocket.OPEN) handleOpen()
+
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
@@ -416,7 +475,7 @@ export const Terminal = (props: TerminalProps) => {
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
- t.write(data)
+ output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
@@ -459,6 +518,9 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
+ if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
+ if (sizeTimer !== undefined) clearTimeout(sizeTimer)
+ output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})
@@ -473,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{
...(local.classList ?? {}),
"select-text": true,
- "size-full px-6 py-3 font-mono": true,
+ "size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index b286364c69f1..03437c973597 100644
--- a/packages/app/src/context/command.tsx
+++ b/packages/app/src/context/command.tsx
@@ -315,8 +315,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
+ const modified = event.ctrlKey || event.metaKey || event.altKey
+ const isTab = event.key === "Tab"
- if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
+ if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
+ return
if (isPalette) {
event.preventDefault()
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
index 346657e2fb61..3f93b76a723c 100644
--- a/packages/app/src/context/global-sdk.tsx
+++ b/packages/app/src/context/global-sdk.tsx
@@ -46,6 +46,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
+ const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
@@ -91,50 +92,58 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
let streamErrorLogged = false
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
void (async () => {
- const events = await eventSdk.global.event({
- onSseError: (error) => {
- if (streamErrorLogged) return
- streamErrorLogged = true
- console.error("[global-sdk] event stream error", {
- url: server.url,
- fetch: eventFetch ? "platform" : "webview",
- error,
+ while (!abort.signal.aborted) {
+ try {
+ const events = await eventSdk.global.event({
+ onSseError: (error) => {
+ if (streamErrorLogged) return
+ streamErrorLogged = true
+ console.error("[global-sdk] event stream error", {
+ url: server.url,
+ fetch: eventFetch ? "platform" : "webview",
+ error,
+ })
+ },
})
- },
- })
- let yielded = Date.now()
- for await (const event of events.stream) {
- const directory = event.directory ?? "global"
- const payload = event.payload
- const k = key(directory, payload)
- if (k) {
- const i = coalesced.get(k)
- if (i !== undefined) {
- queue[i] = { directory, payload }
- continue
+ let yielded = Date.now()
+ for await (const event of events.stream) {
+ streamErrorLogged = false
+ const directory = event.directory ?? "global"
+ const payload = event.payload
+ const k = key(directory, payload)
+ if (k) {
+ const i = coalesced.get(k)
+ if (i !== undefined) {
+ queue[i] = { directory, payload }
+ continue
+ }
+ coalesced.set(k, queue.length)
+ }
+ queue.push({ directory, payload })
+ schedule()
+
+ if (Date.now() - yielded < STREAM_YIELD_MS) continue
+ yielded = Date.now()
+ await wait(0)
+ }
+ } catch (error) {
+ if (!streamErrorLogged) {
+ streamErrorLogged = true
+ console.error("[global-sdk] event stream failed", {
+ url: server.url,
+ fetch: eventFetch ? "platform" : "webview",
+ error,
+ })
}
- coalesced.set(k, queue.length)
}
- queue.push({ directory, payload })
- schedule()
- if (Date.now() - yielded < STREAM_YIELD_MS) continue
- yielded = Date.now()
- await new Promise((resolve) => setTimeout(resolve, 0))
+ if (abort.signal.aborted) return
+ await wait(RECONNECT_DELAY_MS)
}
- })()
- .finally(flush)
- .catch((error) => {
- if (streamErrorLogged) return
- streamErrorLogged = true
- console.error("[global-sdk] event stream failed", {
- url: server.url,
- fetch: eventFetch ? "platform" : "webview",
- error,
- })
- })
+ })().finally(flush)
onCleanup(() => {
abort.abort()
diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts
new file mode 100644
index 000000000000..500f0fc70ae1
--- /dev/null
+++ b/packages/app/src/context/global-sync/child-store.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot, getOwner } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { State } from "./types"
+import { createChildStoreManager } from "./child-store"
+
+const child = () => createStore({} as State)
+
+describe("createChildStoreManager", () => {
+ test("does not evict the active directory during mark", () => {
+ const owner = createRoot((dispose) => {
+ const current = getOwner()
+ dispose()
+ return current
+ })
+ if (!owner) throw new Error("owner required")
+
+ const manager = createChildStoreManager({
+ owner,
+ markStats() {},
+ incrementEvictions() {},
+ isBooting: () => false,
+ isLoadingSessions: () => false,
+ onBootstrap() {},
+ onDispose() {},
+ })
+
+ Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
+ manager.children[directory] = child()
+ manager.pin(directory)
+ })
+
+ const directory = "/active"
+ manager.children[directory] = child()
+ manager.mark(directory)
+
+ expect(manager.children[directory]).toBeDefined()
+ })
+})
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 2feb7fe0884f..af08c3bd431b 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -36,7 +36,7 @@ export function createChildStoreManager(input: {
const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
- runEviction()
+ runEviction(directory)
}
const pin = (directory: string) => {
@@ -106,7 +106,7 @@ export function createChildStoreManager(input: {
return true
}
- function runEviction() {
+ function runEviction(skip?: string) {
const stores = Object.keys(children)
if (stores.length === 0) return
const list = pickDirectoriesToEvict({
@@ -116,7 +116,7 @@ export function createChildStoreManager(input: {
max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS,
now: Date.now(),
- })
+ }).filter((directory) => directory !== skip)
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index fa1a43d4793f..66fcac66d560 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -231,6 +231,24 @@ export function applyDirectoryEvent(input: {
}
break
}
+ case "message.part.delta": {
+ const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "part",
+ props.messageID,
+ produce((draft) => {
+ const part = draft[result.index]
+ const field = props.field as keyof typeof part
+ const existing = part[field] as string | undefined
+ ;(part[field] as string) = (existing ?? "") + props.delta
+ }),
+ )
+ break
+ }
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index a5d894e62eba..b21ec6d3cc84 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -57,6 +57,10 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten
+function cookie(locale: Locale) {
+ return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
+}
+
const LOCALES: readonly Locale[] = [
"en",
"zh",
@@ -199,6 +203,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
+ document.cookie = cookie(locale())
})
return {
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index bf880d115e7f..04bc2fdaaaf3 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -233,7 +233,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (!session) return
if (session.parentID) return
- playSound(soundSrc(settings.sounds.agent()))
+ if (settings.sounds.agentEnabled()) {
+ playSound(soundSrc(settings.sounds.agent()))
+ }
append({
directory,
@@ -260,7 +262,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (meta.disposed) return
if (session?.parentID) return
- playSound(soundSrc(settings.sounds.errors()))
+ if (settings.sounds.errorsEnabled()) {
+ playSound(soundSrc(settings.sounds.errors()))
+ }
const error = "error" in event.properties ? event.properties.error : undefined
append({
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index a8efb1eaced3..fbcd0a851845 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -10,8 +10,11 @@ export interface NotificationSettings {
}
export interface SoundSettings {
+ agentEnabled: boolean
agent: string
+ permissionsEnabled: boolean
permissions: string
+ errorsEnabled: boolean
errors: string
}
@@ -57,8 +60,11 @@ const defaultSettings: Settings = {
errors: false,
},
sounds: {
+ agentEnabled: true,
agent: "staplebops-01",
+ permissionsEnabled: true,
permissions: "staplebops-02",
+ errorsEnabled: true,
errors: "nope-03",
},
}
@@ -79,6 +85,7 @@ const monoFonts: Record = {
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
+ "geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
}
export function monoFontFamily(font: string | undefined) {
@@ -168,14 +175,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
},
sounds: {
+ agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
+ setAgentEnabled(value: boolean) {
+ setStore("sounds", "agentEnabled", value)
+ },
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
+ permissionsEnabled: withFallback(
+ () => store.sounds?.permissionsEnabled,
+ defaultSettings.sounds.permissionsEnabled,
+ ),
+ setPermissionsEnabled(value: boolean) {
+ setStore("sounds", "permissionsEnabled", value)
+ },
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
+ errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
+ setErrorsEnabled(value: boolean) {
+ setStore("sounds", "errorsEnabled", value)
+ },
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index f041204dcc28..3a85086b48b0 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -4,6 +4,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
+import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -68,11 +69,7 @@ const notify: Platform["notify"] = async (title, description, href) => {
})
notification.onclick = () => {
- window.focus()
- if (href) {
- window.history.pushState(null, "", href)
- window.dispatchEvent(new PopStateEvent("popstate"))
- }
+ handleNotificationClick(href)
notification.close()
}
}
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index e3792a3c3cc3..3d347c8423cb 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -509,6 +509,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
+ "sidebar.project.clearNotifications": "مسح الإشعارات",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",
@@ -556,6 +557,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "تنبيه 01",
"sound.option.alert02": "تنبيه 02",
"sound.option.alert03": "تنبيه 03",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 07d6ce467aeb..730c01fdfffb 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -515,6 +515,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões",
+ "sidebar.project.clearNotifications": "Limpar notificações",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Servidor",
@@ -562,6 +563,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index 7d10da6ed875..d53c261126b7 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -576,6 +576,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
"sidebar.project.recentSessions": "Nedavne sesije",
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
+ "sidebar.project.clearNotifications": "Očisti obavijesti",
"app.name.desktop": "OpenCode Desktop",
@@ -630,6 +631,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Upozorenje 01",
"sound.option.alert02": "Upozorenje 02",
"sound.option.alert03": "Upozorenje 03",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index ac5c4d494b12..9faa14d3da4b 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -572,6 +572,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Seneste sessioner",
"sidebar.project.viewAllSessions": "Vis alle sessioner",
+ "sidebar.project.clearNotifications": "Ryd notifikationer",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
@@ -626,6 +627,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 99a950631074..d350af6cf55f 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -524,6 +524,7 @@ export const dict = {
"Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.",
"sidebar.project.recentSessions": "Letzte Sitzungen",
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
+ "sidebar.project.clearNotifications": "Benachrichtigungen löschen",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
@@ -571,6 +572,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index c138c7b61456..cb42b016f1fb 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -109,6 +109,7 @@ export const dict = {
"dialog.model.empty": "No model results",
"dialog.model.manage": "Manage models",
"dialog.model.manage.description": "Customize which models appear in the model selector.",
+ "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
@@ -576,6 +577,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
+ "sidebar.project.clearNotifications": "Clear notifications",
"app.name.desktop": "OpenCode Desktop",
@@ -630,6 +632,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 7a6c4974e091..c4ec378dcdd4 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sesiones recientes",
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
+ "sidebar.project.clearNotifications": "Borrar notificaciones",
"app.name.desktop": "OpenCode Desktop",
@@ -634,6 +635,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index fc3bf2667943..7069fbd98fe1 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -523,6 +523,7 @@ export const dict = {
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes",
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
+ "sidebar.project.clearNotifications": "Effacer les notifications",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Bureau",
"settings.section.server": "Serveur",
@@ -570,6 +571,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerte 01",
"sound.option.alert02": "Alerte 02",
"sound.option.alert03": "Alerte 03",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index b597db02a586..e7e24a9bd68f 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -513,6 +513,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。",
"sidebar.project.recentSessions": "最近のセッション",
"sidebar.project.viewAllSessions": "すべてのセッションを表示",
+ "sidebar.project.clearNotifications": "通知をクリア",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "デスクトップ",
"settings.section.server": "サーバー",
@@ -560,6 +561,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "アラート 01",
"sound.option.alert02": "アラート 02",
"sound.option.alert03": "アラート 03",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 525bd035651f..650b7e662a36 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.",
"sidebar.project.recentSessions": "최근 세션",
"sidebar.project.viewAllSessions": "모든 세션 보기",
+ "sidebar.project.clearNotifications": "알림 지우기",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "데스크톱",
"settings.section.server": "서버",
@@ -561,6 +562,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "알림 01",
"sound.option.alert02": "알림 02",
"sound.option.alert03": "알림 03",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 98e79e1896af..afc162ab1765 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Nylige sesjoner",
"sidebar.project.viewAllSessions": "Vis alle sesjoner",
+ "sidebar.project.clearNotifications": "Fjern varsler",
"app.name.desktop": "OpenCode Desktop",
@@ -633,6 +634,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Varsel 01",
"sound.option.alert02": "Varsel 02",
"sound.option.alert03": "Varsel 03",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 983c9c14ac1b..d8572148a896 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.",
"sidebar.project.recentSessions": "Ostatnie sesje",
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
+ "sidebar.project.clearNotifications": "Wyczyść powiadomienia",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Pulpit",
"settings.section.server": "Serwer",
@@ -561,6 +562,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index f2c87fe0f1ed..86d201cebcab 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -578,6 +578,7 @@ export const dict = {
"Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.",
"sidebar.project.recentSessions": "Недавние сессии",
"sidebar.project.viewAllSessions": "Посмотреть все сессии",
+ "sidebar.project.clearNotifications": "Очистить уведомления",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Приложение",
@@ -631,6 +632,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 689e82118968..83020bf8c07b 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -571,6 +571,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
"sidebar.project.recentSessions": "เซสชันล่าสุด",
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
+ "sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
"app.name.desktop": "OpenCode Desktop",
@@ -625,6 +626,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "เสียงเตือน 01",
"sound.option.alert02": "เสียงเตือน 02",
"sound.option.alert03": "เสียงเตือน 03",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 1b40013b60de..d0bf86cbba6d 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -569,6 +569,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近会话",
"sidebar.project.viewAllSessions": "查看全部会话",
+ "sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
@@ -622,6 +623,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "警报 01",
"sound.option.alert02": "警报 02",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 34aec01b9cb1..349c90b0e111 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -567,6 +567,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近工作階段",
"sidebar.project.viewAllSessions": "查看全部工作階段",
+ "sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "桌面",
@@ -620,6 +621,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
+ "font.option.geistMono": "Geist Mono",
"sound.option.alert01": "警報 01",
"sound.option.alert02": "警報 02",
"sound.option.alert03": "警報 03",
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 59e1431fa8c6..33c22f099e35 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,3 +1,4 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command"
+export { handleNotificationClick } from "./utils/notification-click"
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 5f001177ff5d..7d4a5c0cb81c 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -388,7 +388,9 @@ export default function Layout(props: ParentProps) {
alertedAtBySession.set(sessionKey, now)
if (e.details.type === "permission.asked") {
- playSound(soundSrc(settings.sounds.permissions()))
+ if (settings.sounds.permissionsEnabled()) {
+ playSound(soundSrc(settings.sounds.permissions()))
+ }
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
}
@@ -1690,6 +1692,13 @@ export default function Layout(props: ParentProps) {
})
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
+ const unseenCount = createMemo(() =>
+ workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
+ )
+ const clearNotifications = () =>
+ workspaces()
+ .filter((directory) => notification.project.unseenCount(directory) > 0)
+ .forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => {
const project = panelProps.project
if (!project) return false
@@ -1767,6 +1776,16 @@ export default function Layout(props: ParentProps) {
: language.t("sidebar.workspaces.enable")}
+
+
+ {language.t("sidebar.project.clearNotifications")}
+
+
active: Accessor
overlay: Accessor
+ dirs: Accessor
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -70,73 +72,94 @@ const ProjectTile = (props: {
setMenu: (value: boolean) => void
setOpen: (value: boolean) => void
language: ReturnType
-}): JSX.Element => (
- {
- props.setMenu(value)
- if (value) props.setOpen(false)
- }}
- >
- {
- if (!props.overlay()) return
- props.onProjectMouseEnter(props.project.worktree, event)
- }}
- onMouseLeave={() => {
- if (!props.overlay()) return
- props.onProjectMouseLeave(props.project.worktree)
- }}
- onFocus={() => {
- if (!props.overlay()) return
- props.onProjectFocus(props.project.worktree)
+}): JSX.Element => {
+ const notification = useNotification()
+ const unseenCount = createMemo(() =>
+ props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
+ )
+
+ const clear = () =>
+ props
+ .dirs()
+ .filter((directory) => notification.project.unseenCount(directory) > 0)
+ .forEach((directory) => notification.project.markViewed(directory))
+
+ return (
+ {
+ props.setMenu(value)
+ if (value) props.setOpen(false)
}}
- onClick={() => props.navigateToProject(props.project.worktree)}
- onBlur={() => props.setOpen(false)}
>
-
-
-
-
- props.showEditProjectDialog(props.project)}>
- {props.language.t("common.edit")}
-
- props.toggleProjectWorkspaces(props.project)}
- >
-
- {props.workspacesEnabled(props.project)
- ? props.language.t("sidebar.workspaces.disable")
- : props.language.t("sidebar.workspaces.enable")}
-
-
-
- props.closeProject(props.project.worktree)}
- >
- {props.language.t("common.close")}
-
-
-
-
-)
+ {
+ if (!props.overlay()) return
+ props.onProjectMouseEnter(props.project.worktree, event)
+ }}
+ onMouseLeave={() => {
+ if (!props.overlay()) return
+ props.onProjectMouseLeave(props.project.worktree)
+ }}
+ onFocus={() => {
+ if (!props.overlay()) return
+ props.onProjectFocus(props.project.worktree)
+ }}
+ onClick={() => props.navigateToProject(props.project.worktree)}
+ onBlur={() => props.setOpen(false)}
+ >
+
+
+
+
+ props.showEditProjectDialog(props.project)}>
+ {props.language.t("common.edit")}
+
+ props.toggleProjectWorkspaces(props.project)}
+ >
+
+ {props.workspacesEnabled(props.project)
+ ? props.language.t("sidebar.workspaces.disable")
+ : props.language.t("sidebar.workspaces.enable")}
+
+
+
+ {props.language.t("sidebar.project.clearNotifications")}
+
+
+ props.closeProject(props.project.worktree)}
+ >
+ {props.language.t("common.close")}
+
+
+
+
+ )
+}
const ProjectPreviewPanel = (props: {
project: LocalProject
@@ -254,6 +277,7 @@ export const SortableProject = (props: {
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
+ const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
@@ -304,6 +328,7 @@ export const SortableProject = (props: {
selected={selected}
active={active}
overlay={overlay}
+ dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
onProjectFocus={props.ctx.onProjectFocus}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index edcc660a0f5b..41646d2f6ce1 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -232,7 +232,7 @@ export default function Page() {
})
}
- const isDesktop = createMediaQuery("(min-width: 768px)")
+ const isDesktop = createMediaQuery("(min-width: 1024px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -272,6 +272,7 @@ export default function Page() {
if (!path) return
file.load(path)
openReviewPanel()
+ tabs().setActive(next)
}
createEffect(() => {
@@ -1551,7 +1552,13 @@ export default function Page() {
return (
-
+
state()?.content?.content ?? "")
- const cacheKey = createMemo(() => checksum(contents()))
+ const cacheKey = createMemo(() => sampledChecksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@@ -163,11 +163,20 @@ export function FileTabContent(props: {
return
}
+ const estimateTop = (range: SelectedLineRange) => {
+ const line = Math.max(range.start, range.end)
+ const height = 24
+ const offset = 2
+ return Math.max(0, (line - 1) * height + offset)
+ }
+
+ const large = contents().length > 500_000
+
const next: Record = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
- if (!marker) continue
- next[comment.id] = markerTop(el, marker)
+ if (marker) next[comment.id] = markerTop(el, marker)
+ else if (large) next[comment.id] = estimateTop(comment.selection)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@@ -194,12 +203,12 @@ export function FileTabContent(props: {
}
const marker = findMarker(root, range)
- if (!marker) {
- setNote("draftTop", undefined)
+ if (marker) {
+ setNote("draftTop", markerTop(el, marker))
return
}
- setNote("draftTop", markerTop(el, marker))
+ setNote("draftTop", large ? estimateTop(range) : undefined)
}
const scheduleComments = () => {
diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx
index 6afe8024a5ce..73aebc079aae 100644
--- a/packages/app/src/pages/session/session-mobile-tabs.tsx
+++ b/packages/app/src/pages/session/session-mobile-tabs.tsx
@@ -14,12 +14,17 @@ export function SessionMobileTabs(props: {
-
+
{props.t("session.tab.session")}
diff --git a/packages/app/src/utils/notification-click.test.ts b/packages/app/src/utils/notification-click.test.ts
new file mode 100644
index 000000000000..76535f83a8e6
--- /dev/null
+++ b/packages/app/src/utils/notification-click.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test"
+import { handleNotificationClick } from "./notification-click"
+
+describe("notification click", () => {
+ test("focuses and navigates when href exists", () => {
+ const calls: string[] = []
+ handleNotificationClick("/abc/session/123", {
+ focus: () => calls.push("focus"),
+ location: {
+ assign: (href) => calls.push(href),
+ },
+ })
+ expect(calls).toEqual(["focus", "/abc/session/123"])
+ })
+
+ test("only focuses when href is missing", () => {
+ const calls: string[] = []
+ handleNotificationClick(undefined, {
+ focus: () => calls.push("focus"),
+ location: {
+ assign: (href) => calls.push(href),
+ },
+ })
+ expect(calls).toEqual(["focus"])
+ })
+})
diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts
new file mode 100644
index 000000000000..1234cd1d6290
--- /dev/null
+++ b/packages/app/src/utils/notification-click.ts
@@ -0,0 +1,12 @@
+type WindowTarget = {
+ focus: () => void
+ location: {
+ assign: (href: string) => void
+ }
+}
+
+export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
+ target.focus()
+ if (!href) return
+ target.location.assign(href)
+}
diff --git a/packages/app/src/utils/terminal-writer.test.ts b/packages/app/src/utils/terminal-writer.test.ts
new file mode 100644
index 000000000000..d48dd4f4ed34
--- /dev/null
+++ b/packages/app/src/utils/terminal-writer.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from "bun:test"
+import { terminalWriter } from "./terminal-writer"
+
+describe("terminalWriter", () => {
+ test("buffers and flushes once per schedule", () => {
+ const calls: string[] = []
+ const scheduled: VoidFunction[] = []
+ const writer = terminalWriter(
+ (data) => calls.push(data),
+ (flush) => scheduled.push(flush),
+ )
+
+ writer.push("a")
+ writer.push("b")
+ writer.push("c")
+
+ expect(calls).toEqual([])
+ expect(scheduled).toHaveLength(1)
+
+ scheduled[0]?.()
+ expect(calls).toEqual(["abc"])
+ })
+
+ test("flush is a no-op when empty", () => {
+ const calls: string[] = []
+ const writer = terminalWriter(
+ (data) => calls.push(data),
+ (flush) => flush(),
+ )
+ writer.flush()
+ expect(calls).toEqual([])
+ })
+})
diff --git a/packages/app/src/utils/terminal-writer.ts b/packages/app/src/utils/terminal-writer.ts
new file mode 100644
index 000000000000..b6caff789cf7
--- /dev/null
+++ b/packages/app/src/utils/terminal-writer.ts
@@ -0,0 +1,27 @@
+export function terminalWriter(
+ write: (data: string) => void,
+ schedule: (flush: VoidFunction) => void = queueMicrotask,
+) {
+ let chunks: string[] | undefined
+ let scheduled = false
+
+ const flush = () => {
+ scheduled = false
+ const items = chunks
+ if (!items?.length) return
+ chunks = undefined
+ write(items.join(""))
+ }
+
+ const push = (data: string) => {
+ if (!data) return
+ if (chunks) chunks.push(data)
+ else chunks = [data]
+
+ if (scheduled) return
+ scheduled = true
+ schedule(flush)
+ }
+
+ return { push, flush }
+}
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index d80de55a24b0..0075a949d29f 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.1.63",
+ "version": "1.2.5",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index d2bcaa851b2d..a8e275ba9a55 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -110,6 +110,7 @@ export async function handler(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
+ ...(providerInfo.payloadModifier ?? {}),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
@@ -274,8 +275,8 @@ export async function handler(
part = part.trim()
usageParser.parse(part)
- if (providerInfo.bodyModifier) {
- for (const [k, v] of Object.entries(providerInfo.bodyModifier)) {
+ if (providerInfo.responseModifier) {
+ for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
part = part.replace(k, v)
}
c.enqueue(encoder.encode(part + "\n\n"))
@@ -285,7 +286,7 @@ export async function handler(
}
}
- if (!providerInfo.bodyModifier && providerInfo.format === opts.format) {
+ if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
c.enqueue(value)
}
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index ccc11ba3a0a4..34626c1e9362 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.1.63",
+ "version": "1.2.5",
"private": true,
"type": "module",
"license": "MIT",
@@ -12,7 +12,7 @@
"@opencode-ai/console-resource": "workspace:*",
"@planetscale/database": "1.19.0",
"aws4fetch": "1.0.20",
- "drizzle-orm": "0.41.0",
+ "drizzle-orm": "catalog:",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "catalog:",
@@ -44,7 +44,7 @@
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/node": "catalog:",
- "drizzle-kit": "0.30.5",
+ "drizzle-kit": "catalog:",
"mysql2": "3.14.4",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
diff --git a/packages/console/core/src/drizzle/index.ts b/packages/console/core/src/drizzle/index.ts
index f0f065de4a53..d3a4b63bf3e2 100644
--- a/packages/console/core/src/drizzle/index.ts
+++ b/packages/console/core/src/drizzle/index.ts
@@ -4,7 +4,6 @@ export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
-import type { ExtractTablesWithRelations } from "drizzle-orm"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import { Context } from "../context"
import { memo } from "../util/memo"
@@ -14,7 +13,7 @@ export namespace Database {
PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT,
Record,
- ExtractTablesWithRelations>
+ any
>
const client = memo(() => {
@@ -23,7 +22,7 @@ export namespace Database {
username: Resource.Database.username,
password: Resource.Database.password,
})
- const db = drizzle(result, {})
+ const db = drizzle({ client: result })
return db
})
diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts
index 9a2908e32e3a..da2677844935 100644
--- a/packages/console/core/src/model.ts
+++ b/packages/console/core/src/model.ts
@@ -62,12 +62,13 @@ export namespace ZenData {
apiKey: z.string(),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
+ payloadModifier: z.record(z.string(), z.any()).optional(),
family: z.string().optional(),
})
const ProviderFamilySchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
- bodyModifier: z.record(z.string(), z.string()).optional(),
+ responseModifier: z.record(z.string(), z.string()).optional(),
})
const ModelsSchema = z.object({
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index b612f54308e1..5e8c5841c90a 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.1.63",
+ "version": "1.2.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 864c23382099..99a5ab7d936a 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.1.63",
+ "version": "1.2.5",
"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 526610e6eb18..e89078b8935e 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.1.63",
+ "version": "1.2.5",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts
index 3e14250b1aaa..072567758f9e 100644
--- a/packages/desktop/scripts/predev.ts
+++ b/packages/desktop/scripts/predev.ts
@@ -8,6 +8,8 @@ const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
-await $`cd ../opencode && bun run build --single`
+await (sidecarConfig.ocBinary.includes("-baseline")
+ ? $`cd ../opencode && bun run build --single --baseline`
+ : $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts
index c3019f0b9701..2629eb466c0a 100644
--- a/packages/desktop/scripts/utils.ts
+++ b/packages/desktop/scripts/utils.ts
@@ -8,17 +8,17 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
},
{
rustTarget: "x86_64-apple-darwin",
- ocBinary: "opencode-darwin-x64",
+ ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-pc-windows-msvc",
- ocBinary: "opencode-windows-x64",
+ ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
- ocBinary: "opencode-linux-x64",
+ ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
},
{
diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs
index 85c91f55a69c..d860e1e6a7ca 100644
--- a/packages/desktop/src-tauri/build.rs
+++ b/packages/desktop/src-tauri/build.rs
@@ -1,10 +1,3 @@
fn main() {
- if let Ok(git_ref) = std::env::var("GITHUB_REF") {
- let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref);
- if branch == "beta" {
- println!("cargo:rustc-env=OPENCODE_SQLITE=1");
- }
- }
-
tauri_build::build()
}
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index bec72c04fabc..aa605a9239fd 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -566,8 +566,8 @@ async fn initialize(app: AppHandle) {
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout.
- let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
- let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| {
+ let needs_sqlite_migration = !sqlite_file_exists();
+ let sqlite_done = needs_sqlite_migration.then(|| {
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
"Sqlite file not found, waiting for it to be generated"
@@ -665,12 +665,14 @@ async fn initialize(app: AppHandle) {
}
let _ = server_ready_rx.await;
+
+ tracing::info!("Loading task finished");
}
})
.map_err(|_| ())
.shared();
- let loading_window = if sqlite_enabled
+ let loading_window = if needs_sqlite_migration
&& timeout(Duration::from_secs(1), loading_task.clone())
.await
.is_err()
diff --git a/packages/desktop/src-tauri/src/windows.rs b/packages/desktop/src-tauri/src/windows.rs
index 2ddcb0506d8f..056720055b55 100644
--- a/packages/desktop/src-tauri/src/windows.rs
+++ b/packages/desktop/src-tauri/src/windows.rs
@@ -22,6 +22,8 @@ impl MainWindow {
pub fn create(app: &AppHandle) -> Result {
if let Some(window) = app.get_webview_window(Self::LABEL) {
+ let _ = window.set_focus();
+ let _ = window.unminimize();
return Ok(Self(window));
}
@@ -50,6 +52,9 @@ impl MainWindow {
let window = window_builder.build()?;
+ // Ensure window is focused after creation (e.g., after update/relaunch)
+ let _ = window.set_focus();
+
setup_window_state_listener(app, &window);
#[cfg(windows)]
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 620914dd7e73..ff0a093766ed 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -1,7 +1,14 @@
// @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
-import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app"
+import {
+ AppBaseProviders,
+ AppInterface,
+ PlatformProvider,
+ Platform,
+ useCommand,
+ handleNotificationClick,
+} from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
@@ -329,10 +336,7 @@ const createPlatform = (password: Accessor): Platform => {
void win.show().catch(() => undefined)
void win.unminimize().catch(() => undefined)
void win.setFocus().catch(() => undefined)
- if (href) {
- window.history.pushState(null, "", href)
- window.dispatchEvent(new PopStateEvent("popstate"))
- }
+ handleNotificationClick(href)
notification.close()
}
})
diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx
index ee29827227e5..23a8055c9d6f 100644
--- a/packages/desktop/src/loading.tsx
+++ b/packages/desktop/src/loading.tsx
@@ -5,7 +5,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { Progress } from "@opencode-ai/ui/progress"
import "./styles.css"
-import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { commands, events, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
@@ -29,36 +29,20 @@ render(() => {
channel.onmessage = (next) => setStep(next)
commands.awaitInitialization(channel as any).catch(() => undefined)
- createEffect(() => {
- if (phase() !== "sqlite_waiting") return
-
+ onMount(() => {
setLine(0)
setPercent(0)
const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
- let stop: (() => void) | undefined
- let active = true
-
- void events.sqliteMigrationProgress
- .listen((e) => {
- if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value)))
- if (e.payload.type === "Done") setPercent(100)
- })
- .then((unlisten) => {
- if (active) {
- stop = unlisten
- return
- }
-
- unlisten()
- })
- .catch(() => undefined)
+ const listener = events.sqliteMigrationProgress.listen((e) => {
+ if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value)))
+ if (e.payload.type === "Done") setPercent(100)
+ })
onCleanup(() => {
- active = false
+ listener.then((cb) => cb())
timers.forEach(clearTimeout)
- stop?.()
})
})
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index a86a549495c0..874f2ed21854 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.1.63",
+ "version": "1.2.5",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index 475e6a870de6..00af2842cdd2 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.1.63"
+version = "1.2.5"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.5/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index f2e8e5dc5d9e..d2c4e51e90a2 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.1.63",
+ "version": "1.2.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index a68fd7f3e321..dcfc336d6525 100644
--- a/packages/opencode/AGENTS.md
+++ b/packages/opencode/AGENTS.md
@@ -1,27 +1,10 @@
-# opencode agent guidelines
+# opencode database guide
-## Build/Test Commands
+## Database
-- **Install**: `bun install`
-- **Run**: `bun run --conditions=browser ./src/index.ts`
-- **Typecheck**: `bun run typecheck` (npm run typecheck)
-- **Test**: `bun test` (runs all tests)
-- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
-
-## Code Style
-
-- **Runtime**: Bun with TypeScript ESM modules
-- **Imports**: Use relative imports for local modules, named imports preferred
-- **Types**: Zod schemas for validation, TypeScript interfaces for structure
-- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
-- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
-- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
-
-## Architecture
-
-- **Tools**: Implement `Tool.Info` interface with `execute()` method
-- **Context**: Pass `sessionID` in tool context, use `App.provide()` for DI
-- **Validation**: All inputs validated with Zod schemas
-- **Logging**: Use `Log.create({ service: "name" })` pattern
-- **Storage**: Use `Storage` namespace for persistence
-- **API Client**: The TypeScript TUI (built with SolidJS + OpenTUI) communicates with the OpenCode server using `@opencode-ai/sdk`. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `./script/generate.ts` to regenerate the SDK and related files.
+- **Schema**: Drizzle schema lives in `src/**/*.sql.ts`.
+- **Naming**: tables and columns use snake*case; join columns are `_id`; indexes are `*\_idx`.
+- **Migrations**: generated by Drizzle Kit using `drizzle.config.ts` (schema: `./src/**/*.sql.ts`, output: `./migration`).
+- **Command**: `bun run db generate --name `.
+- **Output**: creates `migration/_/migration.sql` and `snapshot.json`.
+- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode
index e35cc00944d6..d73bbce26776 100755
--- a/packages/opencode/bin/opencode
+++ b/packages/opencode/bin/opencode
@@ -47,20 +47,109 @@ if (!arch) {
const base = "opencode-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode.exe" : "opencode"
+function supportsAvx2() {
+ if (arch !== "x64") return false
+
+ if (platform === "linux") {
+ try {
+ return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
+ } catch {
+ return false
+ }
+ }
+
+ if (platform === "darwin") {
+ try {
+ const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
+ encoding: "utf8",
+ timeout: 1500,
+ })
+ if (result.status !== 0) return false
+ return (result.stdout || "").trim() === "1"
+ } catch {
+ return false
+ }
+ }
+
+ if (platform === "windows") {
+ const cmd =
+ '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
+
+ for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
+ try {
+ const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], {
+ encoding: "utf8",
+ timeout: 3000,
+ windowsHide: true,
+ })
+ if (result.status !== 0) continue
+ const out = (result.stdout || "").trim().toLowerCase()
+ if (out === "true" || out === "1") return true
+ if (out === "false" || out === "0") return false
+ } catch {
+ continue
+ }
+ }
+
+ return false
+ }
+
+ return false
+}
+
+const names = (() => {
+ const avx2 = supportsAvx2()
+ const baseline = arch === "x64" && !avx2
+
+ if (platform === "linux") {
+ const musl = (() => {
+ try {
+ if (fs.existsSync("/etc/alpine-release")) return true
+ } catch {
+ // ignore
+ }
+
+ try {
+ const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
+ const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase()
+ if (text.includes("musl")) return true
+ } catch {
+ // ignore
+ }
+
+ return false
+ })()
+
+ if (musl) {
+ if (arch === "x64") {
+ if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
+ return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
+ }
+ return [`${base}-musl`, base]
+ }
+
+ if (arch === "x64") {
+ if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
+ return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
+ }
+ return [base, `${base}-musl`]
+ }
+
+ if (arch === "x64") {
+ if (baseline) return [`${base}-baseline`, base]
+ return [base, `${base}-baseline`]
+ }
+ return [base]
+})()
+
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules)) {
- const entries = fs.readdirSync(modules)
- for (const entry of entries) {
- if (!entry.startsWith(base)) {
- continue
- }
- const candidate = path.join(modules, entry, "bin", binary)
- if (fs.existsSync(candidate)) {
- return candidate
- }
+ for (const name of names) {
+ const candidate = path.join(modules, name, "bin", binary)
+ if (fs.existsSync(candidate)) return candidate
}
}
const parent = path.dirname(current)
@@ -74,9 +163,9 @@ function findBinary(startDir) {
const resolved = findBinary(scriptDir)
if (!resolved) {
console.error(
- 'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' +
- base +
- '" package',
+ "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " +
+ names.map((n) => `\"${n}\"`).join(" or ") +
+ " package",
)
process.exit(1)
}
diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml
index c4617527d030..c3b727076493 100644
--- a/packages/opencode/bunfig.toml
+++ b/packages/opencode/bunfig.toml
@@ -2,4 +2,6 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
-timeout = 30000 # 30 seconds - allow time for package installation
+# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
+# using --timeout in package.json scripts instead
+# https://github.com/oven-sh/bun/issues/7789
diff --git a/packages/opencode/drizzle.config.ts b/packages/opencode/drizzle.config.ts
new file mode 100644
index 000000000000..1b4fd556e9cb
--- /dev/null
+++ b/packages/opencode/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "drizzle-kit"
+
+export default defineConfig({
+ dialect: "sqlite",
+ schema: "./src/**/*.sql.ts",
+ out: "./migration",
+ dbCredentials: {
+ url: "/home/thdxr/.local/share/opencode/opencode.db",
+ },
+})
diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql b/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql
new file mode 100644
index 000000000000..775c1a1173dc
--- /dev/null
+++ b/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql
@@ -0,0 +1,90 @@
+CREATE TABLE `project` (
+ `id` text PRIMARY KEY,
+ `worktree` text NOT NULL,
+ `vcs` text,
+ `name` text,
+ `icon_url` text,
+ `icon_color` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `time_initialized` integer,
+ `sandboxes` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `message` (
+ `id` text PRIMARY KEY,
+ `session_id` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `part` (
+ `id` text PRIMARY KEY,
+ `message_id` text NOT NULL,
+ `session_id` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `permission` (
+ `project_id` text PRIMARY KEY,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `session` (
+ `id` text PRIMARY KEY,
+ `project_id` text NOT NULL,
+ `parent_id` text,
+ `slug` text NOT NULL,
+ `directory` text NOT NULL,
+ `title` text NOT NULL,
+ `version` text NOT NULL,
+ `share_url` text,
+ `summary_additions` integer,
+ `summary_deletions` integer,
+ `summary_files` integer,
+ `summary_diffs` text,
+ `revert` text,
+ `permission` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `time_compacting` integer,
+ `time_archived` integer,
+ CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `todo` (
+ `session_id` text NOT NULL,
+ `content` text NOT NULL,
+ `status` text NOT NULL,
+ `priority` text NOT NULL,
+ `position` integer NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `todo_pk` PRIMARY KEY(`session_id`, `position`),
+ CONSTRAINT `fk_todo_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `session_share` (
+ `session_id` text PRIMARY KEY,
+ `id` text NOT NULL,
+ `secret` text NOT NULL,
+ `url` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `fk_session_share_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE INDEX `message_session_idx` ON `message` (`session_id`);--> statement-breakpoint
+CREATE INDEX `part_message_idx` ON `part` (`message_id`);--> statement-breakpoint
+CREATE INDEX `part_session_idx` ON `part` (`session_id`);--> statement-breakpoint
+CREATE INDEX `session_project_idx` ON `session` (`project_id`);--> statement-breakpoint
+CREATE INDEX `session_parent_idx` ON `session` (`parent_id`);--> statement-breakpoint
+CREATE INDEX `todo_session_idx` ON `todo` (`session_id`);
\ No newline at end of file
diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json b/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json
new file mode 100644
index 000000000000..ff76ee209a1e
--- /dev/null
+++ b/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json
@@ -0,0 +1,796 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "068758ed-a97a-46f6-8a59-6c639ae7c20c",
+ "prevIds": ["00000000-0000-0000-0000-000000000000"],
+ "ddl": [
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260211171708_add_project_commands/migration.sql b/packages/opencode/migration/20260211171708_add_project_commands/migration.sql
new file mode 100644
index 000000000000..b63f147a0b2b
--- /dev/null
+++ b/packages/opencode/migration/20260211171708_add_project_commands/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `project` ADD `commands` text;
\ No newline at end of file
diff --git a/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json b/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json
new file mode 100644
index 000000000000..1182cc32de90
--- /dev/null
+++ b/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json
@@ -0,0 +1,806 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb",
+ "prevIds": ["068758ed-a97a-46f6-8a59-6c639ae7c20c"],
+ "ddl": [
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql b/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql
new file mode 100644
index 000000000000..3085fe280f3d
--- /dev/null
+++ b/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql
@@ -0,0 +1,11 @@
+CREATE TABLE `control_account` (
+ `email` text NOT NULL,
+ `url` text NOT NULL,
+ `access_token` text NOT NULL,
+ `refresh_token` text NOT NULL,
+ `token_expiry` integer,
+ `active` integer NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `control_account_pk` PRIMARY KEY(`email`, `url`)
+);
diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json b/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json
new file mode 100644
index 000000000000..05c00a10cf3d
--- /dev/null
+++ b/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json
@@ -0,0 +1,897 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "d2736e43-700f-4e9e-8151-9f2f0d967bc8",
+ "prevIds": ["8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb"],
+ "ddl": [
+ {
+ "name": "control_account",
+ "entityType": "tables"
+ },
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token_expiry",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["email", "url"],
+ "nameExplicit": false,
+ "name": "control_account_pk",
+ "entityType": "pks",
+ "table": "control_account"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 99a69c3357a1..99be25372fb7 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,13 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.1.63",
+ "version": "1.2.5",
"name": "opencode",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
- "test": "bun test",
+ "test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -15,7 +15,8 @@
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
- "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'"
+ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
+ "db": "bun drizzle-kit"
},
"bin": {
"opencode": "./bin/opencode"
@@ -42,6 +43,8 @@
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -51,15 +54,15 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
- "@ai-sdk/amazon-bedrock": "3.0.74",
- "@ai-sdk/anthropic": "2.0.58",
+ "@ai-sdk/amazon-bedrock": "3.0.79",
+ "@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
- "@ai-sdk/deepinfra": "1.0.33",
+ "@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
- "@ai-sdk/google-vertex": "3.0.98",
+ "@ai-sdk/google-vertex": "3.0.103",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
@@ -100,6 +103,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
@@ -122,5 +126,8 @@
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5"
+ },
+ "overrides": {
+ "drizzle-orm": "1.0.0-beta.12-a5629fb"
}
}
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index f0b3fa828a78..ddb4769912d7 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -25,6 +25,32 @@ await Bun.write(
)
console.log("Generated models-snapshot.ts")
+// Load migrations from migration directories
+const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
+ .map((entry) => entry.name)
+ .sort()
+
+const migrations = await Promise.all(
+ migrationDirs.map(async (name) => {
+ const file = path.join(dir, "migration", name, "migration.sql")
+ const sql = await Bun.file(file).text()
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
+ const timestamp = match
+ ? Date.UTC(
+ Number(match[1]),
+ Number(match[2]) - 1,
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6]),
+ )
+ : 0
+ return { sql, timestamp }
+ }),
+)
+console.log(`Loaded ${migrations.length} migrations`)
+
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
@@ -156,6 +182,7 @@ for (const item of targets) {
entrypoints: ["./src/index.ts", parserWorker, workerPath],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
+ OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
diff --git a/packages/opencode/script/check-migrations.ts b/packages/opencode/script/check-migrations.ts
new file mode 100644
index 000000000000..f5eaf79323b2
--- /dev/null
+++ b/packages/opencode/script/check-migrations.ts
@@ -0,0 +1,16 @@
+#!/usr/bin/env bun
+
+import { $ } from "bun"
+
+// drizzle-kit check compares schema to migrations, exits non-zero if drift
+const result = await $`bun drizzle-kit check`.quiet().nothrow()
+
+if (result.exitCode !== 0) {
+ console.error("Schema has changes not captured in migrations!")
+ console.error("Run: bun drizzle-kit generate")
+ console.error("")
+ console.error(result.stderr.toString())
+ process.exit(1)
+}
+
+console.log("Migrations are up to date")
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index ae6f6fcc296a..9512406b3d47 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -435,46 +435,68 @@ export namespace ACP {
return
}
}
+ return
+ }
- if (part.type === "text") {
- const delta = props.delta
- if (delta && part.ignored !== true) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_message_chunk",
- content: {
- type: "text",
- text: delta,
- },
+ case "message.part.delta": {
+ const props = event.properties
+ const session = this.sessionManager.tryGet(props.sessionID)
+ if (!session) return
+ const sessionId = session.id
+
+ const message = await this.sdk.session
+ .message(
+ {
+ sessionID: props.sessionID,
+ messageID: props.messageID,
+ directory: session.cwd,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => x.data)
+ .catch((error) => {
+ log.error("unexpected error when fetching message", { error })
+ return undefined
+ })
+
+ if (!message || message.info.role !== "assistant") return
+
+ const part = message.parts.find((p) => p.id === props.partID)
+ if (!part) return
+
+ if (part.type === "text" && props.field === "text" && part.ignored !== true) {
+ await this.connection
+ .sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: {
+ type: "text",
+ text: props.delta,
},
- })
- .catch((error) => {
- log.error("failed to send text to ACP", { error })
- })
- }
+ },
+ })
+ .catch((error) => {
+ log.error("failed to send text delta to ACP", { error })
+ })
return
}
- if (part.type === "reasoning") {
- const delta = props.delta
- if (delta) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_thought_chunk",
- content: {
- type: "text",
- text: delta,
- },
+ if (part.type === "reasoning" && props.field === "text") {
+ await this.connection
+ .sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_thought_chunk",
+ content: {
+ type: "text",
+ text: props.delta,
},
- })
- .catch((error) => {
- log.error("failed to send reasoning to ACP", { error })
- })
- }
+ },
+ })
+ .catch((error) => {
+ log.error("failed to send reasoning delta to ACP", { error })
+ })
}
return
}
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index e338559be7e4..20c6217ecc84 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -253,6 +253,10 @@ export namespace Agent {
return state().then((x) => x[agent])
}
+ export async function reset() {
+ await state.reset()
+ }
+
export async function list() {
const cfg = await Config.get()
return pipe(
diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts
new file mode 100644
index 000000000000..0ade4d3c4bf8
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/db.ts
@@ -0,0 +1,68 @@
+import type { Argv } from "yargs"
+import { spawn } from "child_process"
+import { Database } from "../../storage/db"
+import { Database as BunDatabase } from "bun:sqlite"
+import { UI } from "../ui"
+import { cmd } from "./cmd"
+
+const QueryCommand = cmd({
+ command: "$0 [query]",
+ describe: "open an interactive sqlite3 shell or run a query",
+ builder: (yargs: Argv) => {
+ return yargs
+ .positional("query", {
+ type: "string",
+ describe: "SQL query to execute",
+ })
+ .option("format", {
+ type: "string",
+ choices: ["json", "tsv"],
+ default: "tsv",
+ describe: "Output format",
+ })
+ },
+ handler: async (args: { query?: string; format: string }) => {
+ const query = args.query as string | undefined
+ if (query) {
+ const db = new BunDatabase(Database.Path, { readonly: true })
+ try {
+ const result = db.query(query).all() as Record[]
+ if (args.format === "json") {
+ console.log(JSON.stringify(result, null, 2))
+ } else if (result.length > 0) {
+ const keys = Object.keys(result[0])
+ console.log(keys.join("\t"))
+ for (const row of result) {
+ console.log(keys.map((k) => row[k]).join("\t"))
+ }
+ }
+ } catch (err) {
+ UI.error(err instanceof Error ? err.message : String(err))
+ process.exit(1)
+ }
+ db.close()
+ return
+ }
+ const child = spawn("sqlite3", [Database.Path], {
+ stdio: "inherit",
+ })
+ await new Promise((resolve) => child.on("close", resolve))
+ },
+})
+
+const PathCommand = cmd({
+ command: "path",
+ describe: "print the database path",
+ handler: () => {
+ console.log(Database.Path)
+ },
+})
+
+export const DbCommand = cmd({
+ command: "db",
+ describe: "database tools",
+ builder: (yargs: Argv) => {
+ return yargs.command(QueryCommand).command(PathCommand).demandCommand()
+ },
+ handler: () => {},
+})
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 37419f4e2350..fd45a09b73cc 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -3,7 +3,8 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
-import { Storage } from "../../storage/storage"
+import { Database } from "../../storage/db"
+import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
@@ -130,13 +131,35 @@ export const ImportCommand = cmd({
return
}
- await Storage.write(["session", Instance.project.id, exportData.info.id], exportData.info)
+ Database.use((db) => db.insert(SessionTable).values(Session.toRow(exportData.info)).onConflictDoNothing().run())
for (const msg of exportData.messages) {
- await Storage.write(["message", exportData.info.id, msg.info.id], msg.info)
+ Database.use((db) =>
+ db
+ .insert(MessageTable)
+ .values({
+ id: msg.info.id,
+ session_id: exportData.info.id,
+ time_created: msg.info.time?.created ?? Date.now(),
+ data: msg.info,
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
for (const part of msg.parts) {
- await Storage.write(["part", msg.info.id, part.id], part)
+ Database.use((db) =>
+ db
+ .insert(PartTable)
+ .values({
+ id: part.id,
+ message_id: msg.info.id,
+ session_id: exportData.info.id,
+ data: part,
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
}
}
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 163a5820d99d..55cf9a2a0a3e 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -274,6 +274,10 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
+ .option("dir", {
+ type: "string",
+ describe: "directory to run in, path on remote server if attaching",
+ })
.option("port", {
type: "number",
describe: "port for the local server (defaults to random port if no value provided)",
@@ -293,6 +297,18 @@ export const RunCommand = cmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
+ const directory = (() => {
+ if (!args.dir) return undefined
+ if (args.attach) return args.dir
+ try {
+ process.chdir(args.dir)
+ return process.cwd()
+ } catch {
+ UI.error("Failed to change directory to " + args.dir)
+ process.exit(1)
+ }
+ })()
+
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@@ -390,20 +406,24 @@ export const RunCommand = cmd({
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
- if (part.tool === "bash") return bash(props(part))
- if (part.tool === "glob") return glob(props(part))
- if (part.tool === "grep") return grep(props(part))
- if (part.tool === "list") return list(props(part))
- if (part.tool === "read") return read(props(part))
- if (part.tool === "write") return write(props(part))
- if (part.tool === "webfetch") return webfetch(props(part))
- if (part.tool === "edit") return edit(props(part))
- if (part.tool === "codesearch") return codesearch(props(part))
- if (part.tool === "websearch") return websearch(props(part))
- if (part.tool === "task") return task(props(part))
- if (part.tool === "todowrite") return todo(props(part))
- if (part.tool === "skill") return skill(props(part))
- return fallback(part)
+ try {
+ if (part.tool === "bash") return bash(props(part))
+ if (part.tool === "glob") return glob(props(part))
+ if (part.tool === "grep") return grep(props(part))
+ if (part.tool === "list") return list(props(part))
+ if (part.tool === "read") return read(props(part))
+ if (part.tool === "write") return write(props(part))
+ if (part.tool === "webfetch") return webfetch(props(part))
+ if (part.tool === "edit") return edit(props(part))
+ if (part.tool === "codesearch") return codesearch(props(part))
+ if (part.tool === "websearch") return websearch(props(part))
+ if (part.tool === "task") return task(props(part))
+ if (part.tool === "todowrite") return todo(props(part))
+ if (part.tool === "skill") return skill(props(part))
+ return fallback(part)
+ } catch {
+ return fallback(part)
+ }
}
function emit(type: string, data: Record) {
@@ -582,7 +602,7 @@ export const RunCommand = cmd({
}
if (args.attach) {
- const sdk = createOpencodeClient({ baseUrl: args.attach })
+ const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
return await execute(sdk)
}
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index 9239bb90a67a..04c1fe2ebc64 100644
--- a/packages/opencode/src/cli/cmd/stats.ts
+++ b/packages/opencode/src/cli/cmd/stats.ts
@@ -2,7 +2,8 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
-import { Storage } from "../../storage/storage"
+import { Database } from "../../storage/db"
+import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
@@ -87,25 +88,8 @@ async function getCurrentProject(): Promise {
}
async function getAllSessions(): Promise {
- const sessions: Session.Info[] = []
-
- const projectKeys = await Storage.list(["project"])
- const projects = await Promise.all(projectKeys.map((key) => Storage.read(key)))
-
- for (const project of projects) {
- if (!project) continue
-
- const sessionKeys = await Storage.list(["session", project.id])
- const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read(key)))
-
- for (const session of projectSessions) {
- if (session) {
- sessions.push(session)
- }
- }
- }
-
- return sessions
+ const rows = Database.use((db) => db.select().from(SessionTable).all())
+ return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise {
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index dbad3f699fc6..ab3d09689252 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -1,6 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
-import { TextAttributes } from "@opentui/core"
+import { Selection } from "@tui/util/selection"
+import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@@ -180,6 +181,7 @@ export function tui(input: {
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
+ openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
@@ -209,6 +211,35 @@ function App() {
const exit = useExit()
const promptRef = usePromptRef()
+ useKeyboard((evt) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+ if (!renderer.getSelection()) return
+
+ // Windows Terminal-like behavior:
+ // - Ctrl+C copies and dismisses selection
+ // - Esc dismisses selection
+ // - Most other key input dismisses selection and is passed through
+ if (evt.ctrl && evt.name === "c") {
+ if (!Selection.copy(renderer, toast)) {
+ renderer.clearSelection()
+ return
+ }
+
+ evt.preventDefault()
+ evt.stopPropagation()
+ return
+ }
+
+ if (evt.name === "escape") {
+ renderer.clearSelection()
+ evt.preventDefault()
+ evt.stopPropagation()
+ return
+ }
+
+ renderer.clearSelection()
+ })
+
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
@@ -216,6 +247,7 @@ function App() {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@@ -702,19 +734,15 @@ function App() {
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
- onMouseUp={async () => {
- if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
- renderer.clearSelection()
- return
- }
- const text = renderer.getSelection()?.getSelectedText()
- if (text && text.length > 0) {
- await Clipboard.copy(text)
- .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
- .catch(toast.error)
- renderer.clearSelection()
- }
+ onMouseDown={(evt) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+ if (evt.button !== MouseButton.RIGHT) return
+
+ if (!Selection.copy(renderer, toast)) return
+ evt.preventDefault()
+ evt.stopPropagation()
}}
+ onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index eb8ed2d9bbad..269ed7ae0bd1 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -299,6 +299,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
+ case "message.part.delta": {
+ const parts = store.part[event.properties.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+ if (!result.found) break
+ setStore(
+ "part",
+ event.properties.messageID,
+ produce((draft) => {
+ const part = draft[result.index]
+ const field = event.properties.field as keyof typeof part
+ const existing = part[field] as string | undefined
+ ;(part[field] as string) = (existing ?? "") + event.properties.delta
+ }),
+ )
+ break
+ }
+
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
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 b843bda1c9db..e83b9abe98ae 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -2042,8 +2042,8 @@ function ApplyPatch(props: ToolProps) {
-
- apply_patch
+
+ Patch
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
index 0b57ad29cf41..8cebd9cba54d 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
@@ -1,10 +1,11 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
-import { Renderable, RGBA } from "@opentui/core"
+import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
-import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
+import { Flag } from "@/flag/flag"
+import { Selection } from "@tui/util/selection"
export function Dialog(
props: ParentProps<{
@@ -16,10 +17,18 @@ export function Dialog(
const { theme } = useTheme()
const renderer = useRenderer()
+ let dismiss = false
+
return (
{
- if (renderer.getSelection()) return
+ onMouseDown={() => {
+ dismiss = !!renderer.getSelection()
+ }}
+ onMouseUp={() => {
+ if (dismiss) {
+ dismiss = false
+ return
+ }
props.onClose?.()
}}
width={dimensions().width}
@@ -32,8 +41,8 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
{
- if (renderer.getSelection()) return
+ onMouseUp={(e) => {
+ dismiss = false
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@@ -56,8 +65,13 @@ function init() {
size: "medium" as "medium" | "large",
})
+ const renderer = useRenderer()
+
useKeyboard((evt) => {
- if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
+ if (store.stack.length === 0) return
+ if (evt.defaultPrevented) return
+ if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
+ if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -67,7 +81,6 @@ function init() {
}
})
- const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
{props.children}
{
- const text = renderer.getSelection()?.getSelectedText()
- if (text && text.length > 0) {
- await Clipboard.copy(text)
- .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
- .catch(toast.error)
- renderer.clearSelection()
- }
+ onMouseDown={(evt) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+ if (evt.button !== MouseButton.RIGHT) return
+
+ if (!Selection.copy(renderer, toast)) return
+ evt.preventDefault()
+ evt.stopPropagation()
}}
+ onMouseUp={
+ !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
+ }
>
value.clear()} size={value.size}>
diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts
new file mode 100644
index 000000000000..1230852dcc07
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts
@@ -0,0 +1,25 @@
+import { Clipboard } from "./clipboard"
+
+type Toast = {
+ show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
+ error: (err: unknown) => void
+}
+
+type Renderer = {
+ getSelection: () => { getSelectedText: () => string } | null
+ clearSelection: () => void
+}
+
+export namespace Selection {
+ export function copy(renderer: Renderer, toast: Toast): boolean {
+ const text = renderer.getSelection()?.getSelectedText()
+ if (!text) return false
+
+ Clipboard.copy(text)
+ .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
+ .catch(toast.error)
+
+ renderer.clearSelection()
+ return true
+ }
+}
diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index dce7ac8bbc34..5c67381d9363 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -144,6 +144,10 @@ export namespace Command {
return state().then((x) => x[name])
}
+ export async function reset() {
+ await state.reset()
+ }
+
export async function list() {
return state().then((x) => Object.values(x))
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 8f0f583ea3d6..b7658d05a25f 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -31,6 +31,7 @@ import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
+import { Control } from "@/control"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -53,7 +54,7 @@ export namespace Config {
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
- function mergeConfigConcatArrays(target: Info, source: Info): Info {
+ function merge(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -88,20 +89,21 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- result = mergeConfigConcatArrays(
- result,
- await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
- )
+ result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
log.debug("loaded remote config from well-known", { url: key })
}
}
+ const token = await Control.token()
+ if (token) {
+ }
+
// Global user config overrides remote config.
- result = mergeConfigConcatArrays(result, await global())
+ result = merge(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
- result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
+ result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
@@ -110,7 +112,7 @@ export namespace Config {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
- result = mergeConfigConcatArrays(result, await loadFile(resolved))
+ result = merge(result, await loadFile(resolved))
}
}
}
@@ -153,7 +155,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
- result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
+ result = merge(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -176,7 +178,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (Flag.OPENCODE_CONFIG_CONTENT) {
- result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
+ result = merge(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -186,7 +188,7 @@ export namespace Config {
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
- result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file)))
+ result = merge(result, await loadFile(path.join(managedConfigDir, file)))
}
}
@@ -1361,6 +1363,10 @@ export namespace Config {
return state().then((x) => x.config)
}
+ export async function reset() {
+ await state.reset()
+ }
+
export async function getGlobal() {
return global()
}
diff --git a/packages/opencode/src/control/control.sql.ts b/packages/opencode/src/control/control.sql.ts
new file mode 100644
index 000000000000..7b805c16274b
--- /dev/null
+++ b/packages/opencode/src/control/control.sql.ts
@@ -0,0 +1,22 @@
+import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
+import { eq } from "drizzle-orm"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const ControlAccountTable = sqliteTable(
+ "control_account",
+ {
+ email: text().notNull(),
+ url: text().notNull(),
+ access_token: text().notNull(),
+ refresh_token: text().notNull(),
+ token_expiry: integer(),
+ active: integer({ mode: "boolean" })
+ .notNull()
+ .$default(() => false),
+ ...Timestamps,
+ },
+ (table) => [
+ primaryKey({ columns: [table.email, table.url] }),
+ // uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
+ ],
+)
diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/control/index.ts
new file mode 100644
index 000000000000..f712e88281f2
--- /dev/null
+++ b/packages/opencode/src/control/index.ts
@@ -0,0 +1,67 @@
+import { eq, and } from "drizzle-orm"
+import { Database } from "@/storage/db"
+import { ControlAccountTable } from "./control.sql"
+import z from "zod"
+
+export * from "./control.sql"
+
+export namespace Control {
+ export const Account = z.object({
+ email: z.string(),
+ url: z.string(),
+ })
+ export type Account = z.infer
+
+ function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
+ return {
+ email: row.email,
+ url: row.url,
+ }
+ }
+
+ export function account(): Account | undefined {
+ const row = Database.use((db) =>
+ db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
+ )
+ return row ? fromRow(row) : undefined
+ }
+
+ export async function token(): Promise {
+ const row = Database.use((db) =>
+ db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
+ )
+ if (!row) return undefined
+ if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
+
+ const res = await fetch(`${row.url}/oauth/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: row.refresh_token,
+ }).toString(),
+ })
+
+ if (!res.ok) return
+
+ const json = (await res.json()) as {
+ access_token: string
+ refresh_token?: string
+ expires_in?: number
+ }
+
+ Database.use((db) =>
+ db
+ .update(ControlAccountTable)
+ .set({
+ access_token: json.access_token,
+ refresh_token: json.refresh_token ?? row.refresh_token,
+ token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
+ })
+ .where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
+ .run(),
+ )
+
+ return json.access_token
+ }
+}
diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts
index c4a4747777e2..fdbb881f44b5 100644
--- a/packages/opencode/src/file/watcher.ts
+++ b/packages/opencode/src/file/watcher.ts
@@ -46,7 +46,7 @@ export namespace FileWatcher {
const state = Instance.state(
async () => {
- if (Instance.project.vcs !== "git") return {}
+ if (Instance.project.vcs !== "git" && !Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {}
log.info("init")
const cfg = await Config.get()
const backend = (() => {
@@ -75,7 +75,7 @@ export namespace FileWatcher {
const subs: ParcelWatcher.AsyncSubscription[] = []
const cfgIgnores = cfg.watcher?.ignore ?? []
- if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
+ if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) {
const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend,
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index b11058b34058..30e7f96def41 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -30,14 +30,18 @@ export namespace Flag {
export declare const OPENCODE_CLIENT: string
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
-
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+ export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD")
+ export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS")
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
- export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
+
+ const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
+ export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
+ copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts
index 9e97fae9dfc8..418446958029 100644
--- a/packages/opencode/src/format/formatter.ts
+++ b/packages/opencode/src/format/formatter.ts
@@ -364,3 +364,12 @@ export const ormolu: Info = {
return Bun.which("ormolu") !== null
},
}
+
+export const cljfmt: Info = {
+ name: "cljfmt",
+ command: ["cljfmt", "fix", "--quiet", "$FILE"],
+ extensions: [".clj", ".cljs", ".cljc", ".edn"],
+ async enabled() {
+ return Bun.which("cljfmt") !== null
+ },
+}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 6dc5e99e91ef..39e77782f599 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -26,6 +26,11 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
+import { DbCommand } from "./cli/cmd/db"
+import path from "path"
+import { Global } from "./global"
+import { JsonMigration } from "./storage/json-migration"
+import { Database } from "./storage/db"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -74,6 +79,43 @@ const cli = yargs(hideBin(process.argv))
version: Installation.VERSION,
args: process.argv.slice(2),
})
+
+ const marker = path.join(Global.Path.data, "opencode.db")
+ if (!(await Bun.file(marker).exists())) {
+ const tty = process.stderr.isTTY
+ process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
+ const width = 36
+ const orange = "\x1b[38;5;214m"
+ const muted = "\x1b[0;2m"
+ const reset = "\x1b[0m"
+ let last = -1
+ if (tty) process.stderr.write("\x1b[?25l")
+ try {
+ await JsonMigration.run(Database.Client().$client, {
+ progress: (event) => {
+ const percent = Math.floor((event.current / event.total) * 100)
+ if (percent === last && event.current !== event.total) return
+ last = percent
+ if (tty) {
+ const fill = Math.round((percent / 100) * width)
+ const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
+ process.stderr.write(
+ `\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
+ )
+ if (event.current === event.total) process.stderr.write("\n")
+ } else {
+ process.stderr.write(`sqlite-migration:${percent}${EOL}`)
+ }
+ },
+ })
+ } finally {
+ if (tty) process.stderr.write("\x1b[?25h")
+ else {
+ process.stderr.write(`sqlite-migration:done${EOL}`)
+ }
+ }
+ process.stderr.write("Database migration complete." + EOL)
+ }
})
.usage("\n" + UI.logo())
.completion("completion", "generate shell completion script")
@@ -97,6 +139,7 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand)
.command(PrCommand)
.command(SessionCommand)
+ .command(DbCommand)
.fail((msg, err) => {
if (
msg?.startsWith("Unknown argument") ||
@@ -147,7 +190,7 @@ try {
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
- console.error(e instanceof Error ? e.message : String(e))
+ process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
}
process.exitCode = 1
} finally {
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 3c29fe03d30a..40b44059f664 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -209,6 +209,10 @@ export namespace MCP {
},
)
+ export async function reset() {
+ await state.reset()
+ }
+
// Helper function to fetch prompts for a specific client
async function fetchPromptsForClient(clientName: string, client: Client) {
const prompts = await client.listPrompts().catch((e) => {
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts
index 2481f104ed15..1e1df62a3ce9 100644
--- a/packages/opencode/src/permission/next.ts
+++ b/packages/opencode/src/permission/next.ts
@@ -3,7 +3,8 @@ import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
-import { Storage } from "@/storage/storage"
+import { Database, eq } from "@/storage/db"
+import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
@@ -105,9 +106,12 @@ export namespace PermissionNext {
),
}
- const state = Instance.state(async () => {
+ const state = Instance.state(() => {
const projectID = Instance.project.id
- const stored = await Storage.read(["permission", projectID]).catch(() => [] as Ruleset)
+ const row = Database.use((db) =>
+ db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
+ )
+ const stored = row?.data ?? ([] as Ruleset)
const pending: Record<
string,
@@ -222,7 +226,8 @@ export namespace PermissionNext {
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
- // await Storage.write(["permission", Instance.project.id], s.approved)
+ // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
+ // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
return
}
},
@@ -275,6 +280,7 @@ export namespace PermissionNext {
}
export async function list() {
- return state().then((x) => Object.values(x.pending).map((x) => x.info))
+ const s = await state()
+ return Object.values(s.pending).map((x) => x.info)
}
}
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index 24dc695d6350..d27a16c98388 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -119,6 +119,10 @@ export namespace Plugin {
return state().then((x) => x.hooks)
}
+ export async function reset() {
+ await state.reset()
+ }
+
export async function init() {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index efdcaba99094..3ddcadec9786 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -1,5 +1,4 @@
import { Plugin } from "../plugin"
-import { Share } from "../share/share"
import { Format } from "../format"
import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher"
@@ -13,15 +12,16 @@ import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
+import { HotReload } from "./hotreload"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
- Share.init()
ShareNext.init()
Format.init()
await LSP.init()
FileWatcher.init()
+ HotReload.init()
File.init()
Vcs.init()
Snapshot.init()
diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts
new file mode 100644
index 000000000000..c198a8b8e1ec
--- /dev/null
+++ b/packages/opencode/src/project/hotreload.ts
@@ -0,0 +1,261 @@
+import path from "path"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Agent } from "@/agent/agent"
+import { Command } from "@/command"
+import { Config } from "@/config/config"
+import { FileWatcher } from "@/file/watcher"
+import { Flag } from "@/flag/flag"
+import { MCP } from "@/mcp"
+import { Plugin } from "@/plugin"
+import { SessionStatus } from "@/session/status"
+import { Skill } from "@/skill"
+import { ToolRegistry } from "@/tool/registry"
+import { Log } from "@/util/log"
+import { Instance } from "./instance"
+import z from "zod"
+
+export namespace HotReload {
+ const log = Log.create({ service: "project.hotreload" })
+
+ export const Event = {
+ Changed: BusEvent.define(
+ "opencode.hotreload.changed",
+ z.object({
+ file: z.string(),
+ event: z.enum(["add", "change", "unlink"]),
+ }),
+ ),
+ Applied: BusEvent.define(
+ "opencode.hotreload.applied",
+ z.object({
+ file: z.string(),
+ event: z.enum(["add", "change", "unlink"]),
+ }),
+ ),
+ }
+
+ const watched = new Set([
+ "agent",
+ "agents",
+ "command",
+ "commands",
+ "mode",
+ "modes",
+ "plugin",
+ "plugins",
+ "skill",
+ "skills",
+ "tool",
+ "tools",
+ ])
+
+ function normalize(file: string) {
+ return file.split(path.sep).join("/")
+ }
+
+ function temp(file: string) {
+ const base = file.split("/").at(-1) ?? file
+ if (!base) return true
+ if (base === ".DS_Store" || base === "Thumbs.db") return true
+ if (base.startsWith(".#")) return true
+ if (base.endsWith("~")) return true
+ if (base.endsWith(".tmp")) return true
+ if (base.endsWith(".swp")) return true
+ if (base.endsWith(".swo")) return true
+ if (base.endsWith(".swx")) return true
+ if (base.endsWith(".bak")) return true
+ if (base.endsWith(".orig")) return true
+ if (base.endsWith(".rej")) return true
+ if (base.endsWith(".crdownload")) return true
+ return false
+ }
+
+ function rel(root: string, file: string) {
+ const roots = new Set([normalize(root).replace(/\/+$/, "")])
+ const files = new Set([normalize(file)])
+
+ if (process.platform === "darwin") {
+ for (const item of [...roots]) {
+ if (item.startsWith("/private/")) roots.add(item.slice("/private".length))
+ if (item.startsWith("/var/")) roots.add(`/private${item}`)
+ }
+ for (const item of [...files]) {
+ if (item.startsWith("/private/")) files.add(item.slice("/private".length))
+ if (item.startsWith("/var/")) files.add(`/private${item}`)
+ }
+ }
+
+ for (const rootItem of roots) {
+ for (const fileItem of files) {
+ if (fileItem.includes("/.git/")) continue
+ if (fileItem === rootItem) continue
+ if (!fileItem.startsWith(`${rootItem}/`)) continue
+ return fileItem.slice(rootItem.length + 1)
+ }
+ }
+ }
+
+ export function classify(root: string, file: string) {
+ const relFile = rel(root, file)
+ if (!relFile) return
+ if (temp(relFile)) return
+ if (relFile === "opencode.json") return relFile
+ if (relFile === "opencode.jsonc") return relFile
+ if (relFile === "AGENTS.md") return relFile
+ if (relFile === ".opencode/opencode.json") return relFile
+ if (relFile === ".opencode/opencode.jsonc") return relFile
+ if (!relFile.startsWith(".opencode/")) return
+ if (relFile.startsWith(".opencode/openwork/")) return
+
+ const parts = relFile.split("/")
+ if (parts.length < 3) return
+ if (!watched.has(parts[1])) return
+
+ const base = parts.at(-1) ?? ""
+ if (!base.includes(".")) return
+ return relFile
+ }
+
+ const state = Instance.state(
+ () => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {}
+
+ const cooldown = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS ?? 1500
+ let timer: ReturnType | undefined
+ let busy = false
+ let last = 0
+ let queued = false
+ let latest:
+ | {
+ file: string
+ event: "add" | "change" | "unlink"
+ }
+ | undefined
+
+ const active = () =>
+ Object.values(SessionStatus.list()).filter((status) => status.type === "busy" || status.type === "retry").length
+
+ const reload = async () => {
+ await Config.reset()
+ await Plugin.reset()
+ await MCP.reset()
+ await ToolRegistry.reset()
+ await Skill.reset()
+ await Agent.reset()
+ await Command.reset()
+ }
+
+ const flush = (reason: "timer" | "session" | "api") => {
+ timer = undefined
+ if (busy) return { ok: true, queued, sessions: active() }
+
+ const hit = latest
+ if (!hit) return { ok: true, queued, sessions: active() }
+
+ const sessions = active()
+ if (sessions > 0) {
+ if (!queued) {
+ log.info("hot reload queued", {
+ file: hit.file,
+ event: hit.event,
+ sessions,
+ })
+ }
+ queued = true
+ return { ok: true, queued: true, sessions }
+ }
+
+ const now = Date.now()
+ const wait = cooldown - (now - last)
+ if (wait > 0) {
+ timer = setTimeout(() => flush(reason), wait)
+ return { ok: true, queued: false, sessions, wait }
+ }
+
+ busy = true
+ queued = false
+ latest = undefined
+ last = now
+ const directory = Instance.directory
+ log.info("hot reload triggered", { directory, file: hit.file, event: hit.event, reason })
+ void Instance.provide({
+ directory,
+ async fn() {
+ await reload()
+ await Bus.publish(Event.Applied, {
+ file: hit.file,
+ event: hit.event,
+ })
+ },
+ })
+ .catch((error) => {
+ log.error("hot reload failed", { error, directory, file: hit.file, event: hit.event })
+ })
+ .finally(() => {
+ busy = false
+ if (!latest) return
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => flush("timer"), 0)
+ })
+ return { ok: true, queued: false, sessions }
+ }
+
+ const request = (hit: { file: string; event: "add" | "change" | "unlink" }) => {
+ latest = hit
+ return flush("api")
+ }
+
+ const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => {
+ const rel = classify(Instance.directory, event.properties.file)
+ if (!rel) return
+
+ const hit = {
+ file: rel,
+ event: event.properties.event,
+ } as const
+
+ void Bus.publish(Event.Changed, hit)
+ })
+
+ const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => {
+ if (!queued) return
+ if (timer) return
+ timer = setTimeout(() => flush("session"), 0)
+ })
+
+ log.info("hot reload enabled", { cooldown, mode: "manual" })
+ return {
+ unsubFile,
+ unsubSession,
+ request,
+ clear() {
+ if (!timer) return
+ clearTimeout(timer)
+ timer = undefined
+ },
+ }
+ },
+ async (entry) => {
+ entry.unsubFile?.()
+ entry.unsubSession?.()
+ entry.clear?.()
+ },
+ )
+
+ export function init() {
+ state()
+ }
+
+ export function request(input?: { file?: string; event?: "add" | "change" | "unlink" }) {
+ const entry = state()
+ const req = "request" in entry ? entry.request : undefined
+ if (!req) {
+ return { ok: false, enabled: false }
+ }
+ const file = input?.file?.trim() || "api"
+ const event = input?.event || "change"
+ const result = req({ file, event })
+ return { ...result, enabled: true }
+ }
+}
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 98031f18d3f1..4c7ef6f57a1b 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -63,7 +63,7 @@ export const Instance = {
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
- state(init: () => S, dispose?: (state: Awaited) => Promise): () => S {
+ state(init: () => S, dispose?: (state: Awaited) => Promise): State.Accessor {
return State.create(() => Instance.directory, init, dispose)
},
async dispose() {
diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts
new file mode 100644
index 000000000000..12373244f572
--- /dev/null
+++ b/packages/opencode/src/project/project.sql.ts
@@ -0,0 +1,15 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const ProjectTable = sqliteTable("project", {
+ id: text().primaryKey(),
+ worktree: text().notNull(),
+ vcs: text(),
+ name: text(),
+ icon_url: text(),
+ icon_color: text(),
+ ...Timestamps,
+ time_initialized: integer(),
+ sandboxes: text({ mode: "json" }).notNull().$type(),
+ commands: text({ mode: "json" }).$type<{ start?: string }>(),
+})
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index f6902de4e1bd..8fa0f6c6f00d 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -1,18 +1,18 @@
import z from "zod"
-import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
-import { $ } from "bun"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { ProjectTable } from "./project.sql"
+import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
-import { Session } from "../session"
import { work } from "../util/queue"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
+import { git } from "../util/git"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -50,20 +50,43 @@ export namespace Project {
Updated: BusEvent.define("project.updated", Info),
}
+ type Row = typeof ProjectTable.$inferSelect
+
+ export function fromRow(row: Row): Info {
+ const icon =
+ row.icon_url || row.icon_color
+ ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
+ : undefined
+ return {
+ id: row.id,
+ worktree: row.worktree,
+ vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
+ name: row.name ?? undefined,
+ icon,
+ time: {
+ created: row.time_created,
+ updated: row.time_updated,
+ initialized: row.time_initialized ?? undefined,
+ },
+ sandboxes: row.sandboxes,
+ commands: row.commands ?? undefined,
+ }
+ }
+
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
- const { id, sandbox, worktree, vcs } = await iife(async () => {
+ const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
- const git = await matches.next().then((x) => x.value)
+ const dotgit = await matches.next().then((x) => x.value)
await matches.return()
- if (git) {
- let sandbox = path.dirname(git)
+ if (dotgit) {
+ let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
// cached id calculation
- let id = await Bun.file(path.join(git, "opencode"))
+ let id = await Bun.file(path.join(dotgit, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => undefined)
@@ -79,13 +102,11 @@ export namespace Project {
// generate id from root commit
if (!id) {
- const roots = await $`git rev-list --max-parents=0 --all`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) =>
- x
+ const roots = await git(["rev-list", "--max-parents=0", "--all"], {
+ cwd: sandbox,
+ })
+ .then(async (result) =>
+ (await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
@@ -104,7 +125,7 @@ export namespace Project {
id = roots[0]
if (id) {
- void Bun.file(path.join(git, "opencode"))
+ void Bun.file(path.join(dotgit, "opencode"))
.write(id)
.catch(() => undefined)
}
@@ -119,12 +140,10 @@ export namespace Project {
}
}
- const top = await $`git rev-parse --show-toplevel`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) => path.resolve(sandbox, x.trim()))
+ const top = await git(["rev-parse", "--show-toplevel"], {
+ cwd: sandbox,
+ })
+ .then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
.catch(() => undefined)
if (!top) {
@@ -138,13 +157,11 @@ export namespace Project {
sandbox = top
- const worktree = await $`git rev-parse --git-common-dir`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) => {
- const dirname = path.dirname(x.trim())
+ const worktree = await git(["rev-parse", "--git-common-dir"], {
+ cwd: sandbox,
+ })
+ .then(async (result) => {
+ const dirname = path.dirname((await result.text()).trim())
if (dirname === ".") return sandbox
return dirname
})
@@ -175,47 +192,73 @@ export namespace Project {
}
})
- let existing = await Storage.read(["project", id]).catch(() => undefined)
- if (!existing) {
- existing = {
- id,
- worktree,
- vcs: vcs as Info["vcs"],
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
+ const existing = await iife(async () => {
+ if (row) return fromRow(row)
+ const fresh: Info = {
+ id: data.id,
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
sandboxes: [],
time: {
created: Date.now(),
updated: Date.now(),
},
}
- if (id !== "global") {
- await migrateFromGlobal(id, worktree)
+ if (data.id !== "global") {
+ await migrateFromGlobal(data.id, data.worktree)
}
- }
-
- // migrate old projects before sandboxes
- if (!existing.sandboxes) existing.sandboxes = []
+ return fresh
+ })
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
- worktree,
- vcs: vcs as Info["vcs"],
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
time: {
...existing.time,
updated: Date.now(),
},
}
- if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox)
+ if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
+ result.sandboxes.push(data.sandbox)
result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
- await Storage.write(["project", id], result)
+ const insert = {
+ id: result.id,
+ worktree: result.worktree,
+ vcs: result.vcs ?? null,
+ name: result.name,
+ icon_url: result.icon?.url,
+ icon_color: result.icon?.color,
+ time_created: result.time.created,
+ time_updated: result.time.updated,
+ time_initialized: result.time.initialized,
+ sandboxes: result.sandboxes,
+ commands: result.commands,
+ }
+ const updateSet = {
+ worktree: result.worktree,
+ vcs: result.vcs ?? null,
+ name: result.name,
+ icon_url: result.icon?.url,
+ icon_color: result.icon?.color,
+ time_updated: result.time.updated,
+ time_initialized: result.time.initialized,
+ sandboxes: result.sandboxes,
+ commands: result.commands,
+ }
+ Database.use((db) =>
+ db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
+ )
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
- return { project: result, sandbox }
+ return { project: result, sandbox: data.sandbox }
}
export async function discover(input: Info) {
@@ -248,43 +291,54 @@ export namespace Project {
return
}
- async function migrateFromGlobal(newProjectID: string, worktree: string) {
- const globalProject = await Storage.read(["project", "global"]).catch(() => undefined)
- if (!globalProject) return
+ async function migrateFromGlobal(id: string, worktree: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
+ if (!row) return
- const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
- if (globalSessions.length === 0) return
+ const sessions = Database.use((db) =>
+ db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
+ )
+ if (sessions.length === 0) return
- log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
+ log.info("migrating sessions from global", { newProjectID: id, worktree, count: sessions.length })
- await work(10, globalSessions, async (key) => {
- const sessionID = key[key.length - 1]
- const session = await Storage.read(key).catch(() => undefined)
- if (!session) return
- if (session.directory && session.directory !== worktree) return
+ await work(10, sessions, async (row) => {
+ // Skip sessions that belong to a different directory
+ if (row.directory && row.directory !== worktree) return
- session.projectID = newProjectID
- log.info("migrating session", { sessionID, from: "global", to: newProjectID })
- await Storage.write(["session", newProjectID, sessionID], session)
- await Storage.remove(key)
+ log.info("migrating session", { sessionID: row.id, from: "global", to: id })
+ Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
}).catch((error) => {
- log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
+ log.error("failed to migrate sessions from global to project", { error, projectId: id })
})
}
- export async function setInitialized(projectID: string) {
- await Storage.update(["project", projectID], (draft) => {
- draft.time.initialized = Date.now()
- })
+ export function setInitialized(id: string) {
+ Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({
+ time_initialized: Date.now(),
+ })
+ .where(eq(ProjectTable.id, id))
+ .run(),
+ )
}
- export async function list() {
- const keys = await Storage.list(["project"])
- const projects = await Promise.all(keys.map((x) => Storage.read(x)))
- return projects.map((project) => ({
- ...project,
- sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
- }))
+ export function list() {
+ return Database.use((db) =>
+ db
+ .select()
+ .from(ProjectTable)
+ .all()
+ .map((row) => fromRow(row)),
+ )
+ }
+
+ export function get(id: string): Info | undefined {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) return undefined
+ return fromRow(row)
}
export const update = fn(
@@ -295,77 +349,90 @@ export namespace Project {
commands: Info.shape.commands.optional(),
}),
async (input) => {
- const result = await Storage.update(["project", input.projectID], (draft) => {
- if (input.name !== undefined) draft.name = input.name
- if (input.icon !== undefined) {
- draft.icon = {
- ...draft.icon,
- }
- if (input.icon.url !== undefined) draft.icon.url = input.icon.url
- if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
- if (input.icon.color !== undefined) draft.icon.color = input.icon.color
- }
-
- if (input.commands?.start !== undefined) {
- const start = input.commands.start || undefined
- draft.commands = {
- ...(draft.commands ?? {}),
- }
- draft.commands.start = start
- if (!draft.commands.start) draft.commands = undefined
- }
-
- draft.time.updated = Date.now()
- })
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({
+ name: input.name,
+ icon_url: input.icon?.url,
+ icon_color: input.icon?.color,
+ commands: input.commands,
+ time_updated: Date.now(),
+ })
+ .where(eq(ProjectTable.id, input.projectID))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${input.projectID}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
},
)
- export async function sandboxes(projectID: string) {
- const project = await Storage.read(["project", projectID]).catch(() => undefined)
- if (!project?.sandboxes) return []
+ export async function sandboxes(id: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) return []
+ const data = fromRow(row)
const valid: string[] = []
- for (const dir of project.sandboxes) {
- const stat = await fs.stat(dir).catch(() => undefined)
+ for (const dir of data.sandboxes) {
+ const stat = await Bun.file(dir)
+ .stat()
+ .catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir)
}
return valid
}
- export async function addSandbox(projectID: string, directory: string) {
- const result = await Storage.update(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- if (!sandboxes.includes(directory)) sandboxes.push(directory)
- draft.sandboxes = sandboxes
- draft.time.updated = Date.now()
- })
+ export async function addSandbox(id: string, directory: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) throw new Error(`Project not found: ${id}`)
+ const sandboxes = [...row.sandboxes]
+ if (!sandboxes.includes(directory)) sandboxes.push(directory)
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({ sandboxes, time_updated: Date.now() })
+ .where(eq(ProjectTable.id, id))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${id}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
}
- export async function removeSandbox(projectID: string, directory: string) {
- const result = await Storage.update(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
- draft.time.updated = Date.now()
- })
+ export async function removeSandbox(id: string, directory: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) throw new Error(`Project not found: ${id}`)
+ const sandboxes = row.sandboxes.filter((s) => s !== directory)
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({ sandboxes, time_updated: Date.now() })
+ .where(eq(ProjectTable.id, id))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${id}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
}
}
diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts
index a9dce565b5eb..8c13978a452a 100644
--- a/packages/opencode/src/project/state.ts
+++ b/packages/opencode/src/project/state.ts
@@ -1,6 +1,10 @@
import { Log } from "@/util/log"
export namespace State {
+ export type Accessor = (() => S) & {
+ reset: () => Promise
+ }
+
interface Entry {
state: any
dispose?: (state: any) => Promise
@@ -9,8 +13,8 @@ export namespace State {
const log = Log.create({ service: "state" })
const recordsByKey = new Map>()
- export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) {
- return () => {
+ export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise): Accessor {
+ const fn = (() => {
const key = root()
let entries = recordsByKey.get(key)
if (!entries) {
@@ -25,7 +29,32 @@ export namespace State {
dispose,
})
return state
+ }) as Accessor
+
+ fn.reset = async () => {
+ await disposeInit(root(), init)
}
+
+ return fn
+ }
+
+ async function disposeInit(key: string, init: any) {
+ const entries = recordsByKey.get(key)
+ if (!entries) return
+ const entry = entries.get(init)
+ if (!entry) return
+
+ if (entry.dispose) {
+ await Promise.resolve(entry.state)
+ .then((state) => entry.dispose!(state))
+ .catch((error) => {
+ const label = typeof init === "function" ? init.name : String(init)
+ log.error("Error while disposing state:", { error, key, init: label })
+ })
+ }
+
+ entries.delete(init)
+ if (!entries.size) recordsByKey.delete(key)
}
export async function dispose(key: string) {
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index d76cc902ae6d..44bcf8adb3de 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -14,6 +14,8 @@ import { Env } from "../env"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
+import { Global } from "../global"
+import path from "path"
// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -1229,9 +1231,19 @@ export namespace Provider {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
- const provider = await list()
- .then((val) => Object.values(val))
- .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
+ const providers = await list()
+ const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
+ .json()
+ .then((x) => (Array.isArray(x.recent) ? x.recent : []))
+ .catch(() => [])) as { providerID: string; modelID: string }[]
+ for (const entry of recent) {
+ const provider = providers[entry.providerID]
+ if (!provider) continue
+ if (!provider.models[entry.modelID]) continue
+ return { providerID: entry.providerID, modelID: entry.modelID }
+ }
+
+ const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 876a26fce719..853d03c1d8b9 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -171,7 +171,7 @@ export namespace ProviderTransform {
return msgs
}
- function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] {
+ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
@@ -194,7 +194,7 @@ export namespace ProviderTransform {
}
for (const msg of unique([...system, ...final])) {
- const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
+ const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
@@ -253,14 +253,15 @@ export namespace ProviderTransform {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
if (
- model.providerID === "anthropic" ||
- model.api.id.includes("anthropic") ||
- model.api.id.includes("claude") ||
- model.id.includes("anthropic") ||
- model.id.includes("claude") ||
- model.api.npm === "@ai-sdk/anthropic"
+ (model.providerID === "anthropic" ||
+ model.api.id.includes("anthropic") ||
+ model.api.id.includes("claude") ||
+ model.id.includes("anthropic") ||
+ model.id.includes("claude") ||
+ model.api.npm === "@ai-sdk/anthropic") &&
+ model.api.npm !== "@ai-sdk/gateway"
) {
- msgs = applyCaching(msgs, model.providerID)
+ msgs = applyCaching(msgs, model)
}
// Remap providerOptions keys from stored providerID to expected SDK key
@@ -360,11 +361,53 @@ export namespace ProviderTransform {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
- if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
+ if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {}
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
- // TODO: YOU CANNOT SET max_tokens if this is set!!!
case "@ai-sdk/gateway":
+ if (model.id.includes("anthropic")) {
+ return {
+ high: {
+ thinking: {
+ type: "enabled",
+ budgetTokens: 16000,
+ },
+ },
+ max: {
+ thinking: {
+ type: "enabled",
+ budgetTokens: 31999,
+ },
+ },
+ }
+ }
+ if (model.id.includes("google")) {
+ if (id.includes("2.5")) {
+ return {
+ high: {
+ thinkingConfig: {
+ includeThoughts: true,
+ thinkingBudget: 16000,
+ },
+ },
+ max: {
+ thinkingConfig: {
+ includeThoughts: true,
+ thinkingBudget: 24576,
+ },
+ },
+ }
+ }
+ return Object.fromEntries(
+ ["low", "high"].map((effort) => [
+ effort,
+ {
+ includeThoughts: true,
+ thinkingLevel: effort,
+ },
+ ]),
+ )
+ }
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
@@ -458,6 +501,22 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
+
+ if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
+ const efforts = ["low", "medium", "high", "max"]
+ return Object.fromEntries(
+ efforts.map((effort) => [
+ effort,
+ {
+ thinking: {
+ type: "adaptive",
+ },
+ effort,
+ },
+ ]),
+ )
+ }
+
return {
high: {
thinking: {
@@ -475,6 +534,20 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
+ if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
+ const efforts = ["low", "medium", "high", "max"]
+ return Object.fromEntries(
+ efforts.map((effort) => [
+ effort,
+ {
+ reasoningConfig: {
+ type: "adaptive",
+ maxReasoningEffort: effort,
+ },
+ },
+ ]),
+ )
+ }
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) {
return {
@@ -690,6 +763,15 @@ export namespace ProviderTransform {
result["promptCacheKey"] = input.sessionID
}
+ if (input.model.providerID === "openrouter") {
+ result["prompt_cache_key"] = input.sessionID
+ }
+ if (input.model.api.npm === "@ai-sdk/gateway") {
+ result["gateway"] = {
+ caching: "auto",
+ }
+ }
+
return result
}
@@ -723,7 +805,43 @@ export namespace ProviderTransform {
return {}
}
+ // Maps model ID prefix to provider slug used in providerOptions.
+ // Example: "amazon/nova-2-lite" → "bedrock"
+ const SLUG_OVERRIDES: Record = {
+ amazon: "bedrock",
+ }
+
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
+ if (model.api.npm === "@ai-sdk/gateway") {
+ // Gateway providerOptions are split across two namespaces:
+ // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.)
+ // - ``: provider-specific model options (anthropic/openai/...)
+ // We keep `gateway` as-is and route every other top-level option under the
+ // model-derived upstream slug.
+ const i = model.api.id.indexOf("/")
+ const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined
+ const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined
+ const gateway = options.gateway
+ const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway"))
+ const has = Object.keys(rest).length > 0
+
+ const result: Record = {}
+ if (gateway !== undefined) result.gateway = gateway
+
+ if (has) {
+ if (slug) {
+ // Route model-specific options under the provider slug
+ result[slug] = rest
+ } else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) {
+ result.gateway = { ...gateway, ...rest }
+ } else {
+ result.gateway = rest
+ }
+ }
+
+ return result
+ }
+
const key = sdkKey(model.api.npm) ?? model.providerID
return { [key]: options }
}
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index 7a07e3ef3234..a9052a79eb8c 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -4,7 +4,6 @@ import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
-import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
@@ -17,6 +16,22 @@ export namespace Pty {
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
+ type Socket = {
+ readyState: number
+ send: (data: string | Uint8Array | ArrayBuffer) => void
+ close: (code?: number, reason?: string) => void
+ }
+
+ const sockets = new WeakMap()
+ let socketCounter = 0
+
+ const tagSocket = (ws: Socket) => {
+ if (!ws || typeof ws !== "object") return
+ const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
+ sockets.set(ws, next)
+ return next
+ }
+
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -81,7 +96,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
- subscribers: Set
+ subscribers: Map
}
const state = Instance.state(
@@ -91,8 +106,12 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
- for (const ws of session.subscribers) {
- ws.close()
+ for (const ws of session.subscribers.keys()) {
+ try {
+ ws.close()
+ } catch {
+ // ignore
+ }
}
}
sessions.clear()
@@ -154,18 +173,26 @@ export namespace Pty {
buffer: "",
bufferCursor: 0,
cursor: 0,
- subscribers: new Set(),
+ subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData((data) => {
session.cursor += data.length
- for (const ws of session.subscribers) {
+ for (const [ws, id] of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
- ws.send(data)
+ if (typeof ws === "object" && sockets.get(ws) !== id) {
+ session.subscribers.delete(ws)
+ continue
+ }
+ try {
+ ws.send(data)
+ } catch {
+ session.subscribers.delete(ws)
+ }
}
session.buffer += data
@@ -177,14 +204,15 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
- for (const ws of session.subscribers) {
- ws.close()
+ for (const ws of session.subscribers.keys()) {
+ try {
+ ws.close()
+ } catch {
+ // ignore
+ }
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
- for (const ws of session.subscribers) {
- ws.close()
- }
state().delete(id)
})
Bus.publish(Event.Created, { info })
@@ -211,9 +239,14 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
- for (const ws of session.subscribers) {
- ws.close()
+ for (const ws of session.subscribers.keys()) {
+ try {
+ ws.close()
+ } catch {
+ // ignore
+ }
}
+ session.subscribers.clear()
state().delete(id)
Bus.publish(Event.Deleted, { id })
}
@@ -232,7 +265,7 @@ export namespace Pty {
}
}
- export function connect(id: string, ws: WSContext, cursor?: number) {
+ export function connect(id: string, ws: Socket, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
@@ -272,7 +305,8 @@ export namespace Pty {
return
}
- session.subscribers.add(ws)
+ const socketId = tagSocket(ws)
+ if (typeof socketId === "number") session.subscribers.set(ws, socketId)
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts
index 26e2dfcb1217..cc5fa961877a 100644
--- a/packages/opencode/src/server/error.ts
+++ b/packages/opencode/src/server/error.ts
@@ -1,6 +1,6 @@
import { resolver } from "hono-openapi"
import z from "zod"
-import { Storage } from "../storage/storage"
+import { NotFoundError } from "../storage/db"
export const ERRORS = {
400: {
@@ -25,7 +25,7 @@ export const ERRORS = {
description: "Not found",
content: {
"application/json": {
- schema: resolver(Storage.NotFoundError.Schema),
+ schema: resolver(NotFoundError.Schema),
},
},
},
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index 3c28331bd529..7fe1f3f08962 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -6,12 +6,60 @@ import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
+import { HotReload } from "../../project/hotreload"
+import { Flag } from "../../flag/flag"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ExperimentalRoutes = lazy(() =>
new Hono()
+ .post(
+ "/hotreload",
+ describeRoute({
+ summary: "Apply hot reload",
+ description:
+ "Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.",
+ operationId: "experimental.hotreload.apply",
+ responses: {
+ 200: {
+ description: "Hot reload scheduled",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z
+ .object({
+ ok: z.boolean(),
+ enabled: z.boolean(),
+ queued: z.boolean().optional(),
+ sessions: z.number().optional(),
+ wait: z.number().optional(),
+ })
+ .meta({ ref: "ExperimentalHotReloadResult" }),
+ ),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z
+ .object({
+ file: z.string().optional(),
+ event: z.enum(["add", "change", "unlink"]).optional(),
+ })
+ .optional(),
+ ),
+ async (c) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) {
+ return c.json({ ok: false, enabled: false }, 400)
+ }
+ const body = c.req.valid("json")
+ return c.json(HotReload.request(body ?? undefined))
+ },
+ )
.get(
"/tool/ids",
describeRoute({
diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts
index 1085c1175b98..21156190dce4 100644
--- a/packages/opencode/src/server/routes/pty.ts
+++ b/packages/opencode/src/server/routes/pty.ts
@@ -3,7 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
-import { Storage } from "../../storage/storage"
+import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -76,7 +76,7 @@ export const PtyRoutes = lazy(() =>
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
- throw new Storage.NotFoundError({ message: "Session not found" })
+ throw new NotFoundError({ message: "Session not found" })
}
return c.json(info)
},
@@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() =>
})()
let handler: ReturnType
if (!Pty.get(id)) throw new Error("Session not found")
+
+ type Socket = {
+ readyState: number
+ send: (data: string | Uint8Array | ArrayBuffer) => void
+ close: (code?: number, reason?: string) => void
+ }
+
+ const isSocket = (value: unknown): value is Socket => {
+ if (!value || typeof value !== "object") return false
+ if (!("readyState" in value)) return false
+ if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
+ if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
+ return typeof (value as { readyState?: unknown }).readyState === "number"
+ }
+
return {
onOpen(_event, ws) {
- handler = Pty.connect(id, ws, cursor)
+ const socket = isSocket(ws.raw) ? ws.raw : ws
+ handler = Pty.connect(id, socket, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
@@ -170,6 +186,9 @@ export const PtyRoutes = lazy(() =>
onClose() {
handler?.onClose()
},
+ onError() {
+ handler?.onClose()
+ },
}
}),
),
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 82e6f3121bf7..1195529e06a8 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -53,15 +53,15 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const query = c.req.valid("query")
- const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
- for await (const session of Session.list()) {
- if (query.directory !== undefined && session.directory !== query.directory) continue
- if (query.roots && session.parentID) continue
- if (query.start !== undefined && session.time.updated < query.start) continue
- if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
+ for await (const session of Session.list({
+ directory: query.directory,
+ roots: query.roots,
+ start: query.start,
+ search: query.search,
+ limit: query.limit,
+ })) {
sessions.push(session)
- if (query.limit !== undefined && sessions.length >= query.limit) break
}
return c.json(sessions)
},
@@ -276,18 +276,15 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
- const updatedSession = await Session.update(
- sessionID,
- (session) => {
- if (updates.title !== undefined) {
- session.title = updates.title
- }
- if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
- },
- { touch: false },
- )
+ let session = await Session.get(sessionID)
+ if (updates.title !== undefined) {
+ session = await Session.setTitle({ sessionID, title: updates.title })
+ }
+ if (updates.time?.archived !== undefined) {
+ session = await Session.setArchived({ sessionID, time: updates.time.archived })
+ }
- return c.json(updatedSession)
+ return c.json(session)
},
)
.post(
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 9fb5206551b6..c1896a8d1393 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -31,7 +31,7 @@ import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
-import { Storage } from "../storage/storage"
+import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
@@ -65,7 +65,7 @@ export namespace Server {
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
- if (err instanceof Storage.NotFoundError) status = 404
+ if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index b0ffaaf70d77..255f4dd46010 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -10,7 +10,9 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
-import { Storage } from "../storage/storage"
+import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
+import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
@@ -41,6 +43,64 @@ export namespace Session {
).test(title)
}
+ type SessionRow = typeof SessionTable.$inferSelect
+
+ export function fromRow(row: SessionRow): Info {
+ const summary =
+ row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null
+ ? {
+ additions: row.summary_additions ?? 0,
+ deletions: row.summary_deletions ?? 0,
+ files: row.summary_files ?? 0,
+ diffs: row.summary_diffs ?? undefined,
+ }
+ : undefined
+ const share = row.share_url ? { url: row.share_url } : undefined
+ const revert = row.revert ?? undefined
+ return {
+ id: row.id,
+ slug: row.slug,
+ projectID: row.project_id,
+ directory: row.directory,
+ parentID: row.parent_id ?? undefined,
+ title: row.title,
+ version: row.version,
+ summary,
+ share,
+ revert,
+ permission: row.permission ?? undefined,
+ time: {
+ created: row.time_created,
+ updated: row.time_updated,
+ compacting: row.time_compacting ?? undefined,
+ archived: row.time_archived ?? undefined,
+ },
+ }
+ }
+
+ export function toRow(info: Info) {
+ return {
+ id: info.id,
+ project_id: info.projectID,
+ parent_id: info.parentID,
+ slug: info.slug,
+ directory: info.directory,
+ title: info.title,
+ version: info.version,
+ share_url: info.share?.url,
+ summary_additions: info.summary?.additions,
+ summary_deletions: info.summary?.deletions,
+ summary_files: info.summary?.files,
+ summary_diffs: info.summary?.diffs,
+ revert: info.revert ?? null,
+ permission: info.permission,
+ time_created: info.time.created,
+ time_updated: info.time.updated,
+ time_compacting: info.time.compacting,
+ time_archived: info.time.archived,
+ }
+ }
+
function getForkedTitle(title: string): string {
const match = title.match(/^(.+) \(fork #(\d+)\)$/)
if (match) {
@@ -94,16 +154,6 @@ export namespace Session {
})
export type Info = z.output
- export const ShareInfo = z
- .object({
- secret: z.string(),
- url: z.string(),
- })
- .meta({
- ref: "SessionShare",
- })
- export type ShareInfo = z.output
-
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -200,8 +250,17 @@ export namespace Session {
)
export const touch = fn(Identifier.schema("session"), async (sessionID) => {
- await update(sessionID, (draft) => {
- draft.time.updated = Date.now()
+ const now = Date.now()
+ Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ time_updated: now })
+ .where(eq(SessionTable.id, sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
})
})
@@ -227,21 +286,19 @@ export namespace Session {
},
}
log.info("created", result)
- await Storage.write(["session", Instance.project.id, result.id], result)
- Bus.publish(Event.Created, {
- info: result,
+ Database.use((db) => {
+ db.insert(SessionTable).values(toRow(result)).run()
+ Database.effect(() =>
+ Bus.publish(Event.Created, {
+ info: result,
+ }),
+ )
})
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
- share(result.id)
- .then((share) => {
- update(result.id, (draft) => {
- draft.share = share
- })
- })
- .catch(() => {
- // Silently ignore sharing errors during session creation
- })
+ share(result.id).catch(() => {
+ // Silently ignore sharing errors during session creation
+ })
Bus.publish(Event.Updated, {
info: result,
})
@@ -256,12 +313,9 @@ export namespace Session {
}
export const get = fn(Identifier.schema("session"), async (id) => {
- const read = await Storage.read(["session", Instance.project.id, id])
- return read as Info
- })
-
- export const getShare = fn(Identifier.schema("session"), async (id) => {
- return Storage.read(["share", id])
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ return fromRow(row)
})
export const share = fn(Identifier.schema("session"), async (id) => {
@@ -271,15 +325,12 @@ export namespace Session {
}
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
- await update(
- id,
- (draft) => {
- draft.share = {
- url: share.url,
- }
- },
- { touch: false },
- )
+ Database.use((db) => {
+ const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ })
return share
})
@@ -287,32 +338,155 @@ export namespace Session {
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
- await update(
- id,
- (draft) => {
- draft.share = undefined
- },
- { touch: false },
- )
+ Database.use((db) => {
+ const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ })
})
- export async function update(id: string, editor: (session: Info) => void, options?: { touch?: boolean }) {
- const project = Instance.project
- const result = await Storage.update(["session", project.id, id], (draft) => {
- editor(draft)
- if (options?.touch !== false) {
- draft.time.updated = Date.now()
- }
- })
- Bus.publish(Event.Updated, {
- info: result,
+ export const setTitle = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ title: z.string(),
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ title: input.title })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setArchived = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ time: z.number().optional(),
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ time_archived: input.time })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setPermission = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ permission: PermissionNext.Ruleset,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ permission: input.permission, time_updated: Date.now() })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setRevert = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ revert: Info.shape.revert,
+ summary: Info.shape.summary,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ revert: input.revert ?? null,
+ summary_additions: input.summary?.additions,
+ summary_deletions: input.summary?.deletions,
+ summary_files: input.summary?.files,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ revert: null,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
})
- return result
- }
+ })
+
+ export const setSummary = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ summary: Info.shape.summary,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ summary_additions: input.summary?.additions,
+ summary_deletions: input.summary?.deletions,
+ summary_files: input.summary?.files,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
export const diff = fn(Identifier.schema("session"), async (sessionID) => {
- const diffs = await Storage.read(["session_diff", sessionID])
- return diffs ?? []
+ try {
+ return await Storage.read(["session_diff", sessionID])
+ } catch {
+ return []
+ }
})
export const messages = fn(
@@ -331,25 +505,55 @@ export namespace Session {
},
)
- export async function* list() {
+ export function* list(input?: {
+ directory?: string
+ roots?: boolean
+ start?: number
+ search?: string
+ limit?: number
+ }) {
const project = Instance.project
- for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read(item).catch(() => undefined)
- if (!session) continue
- yield session
+ const conditions = [eq(SessionTable.project_id, project.id)]
+
+ if (input?.directory) {
+ conditions.push(eq(SessionTable.directory, input.directory))
+ }
+ if (input?.roots) {
+ conditions.push(isNull(SessionTable.parent_id))
+ }
+ if (input?.start) {
+ conditions.push(gte(SessionTable.time_updated, input.start))
+ }
+ if (input?.search) {
+ conditions.push(like(SessionTable.title, `%${input.search}%`))
+ }
+
+ const limit = input?.limit ?? 100
+
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(SessionTable)
+ .where(and(...conditions))
+ .orderBy(desc(SessionTable.time_updated))
+ .limit(limit)
+ .all(),
+ )
+ for (const row of rows) {
+ yield fromRow(row)
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
- const result = [] as Session.Info[]
- for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read(item).catch(() => undefined)
- if (!session) continue
- if (session.parentID !== parentID) continue
- result.push(session)
- }
- return result
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(SessionTable)
+ .where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
+ .all(),
+ )
+ return rows.map(fromRow)
})
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
@@ -360,15 +564,14 @@ export namespace Session {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
- for (const msg of await Storage.list(["message", sessionID])) {
- for (const part of await Storage.list(["part", msg.at(-1)!])) {
- await Storage.remove(part)
- }
- await Storage.remove(msg)
- }
- await Storage.remove(["session", project.id, sessionID])
- Bus.publish(Event.Deleted, {
- info: session,
+ // CASCADE delete handles messages and parts automatically
+ Database.use((db) => {
+ db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
+ Database.effect(() =>
+ Bus.publish(Event.Deleted, {
+ info: session,
+ }),
+ )
})
} catch (e) {
log.error(e)
@@ -376,9 +579,23 @@ export namespace Session {
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
- await Storage.write(["message", msg.sessionID, msg.id], msg)
- Bus.publish(MessageV2.Event.Updated, {
- info: msg,
+ const time_created = msg.role === "user" ? msg.time.created : msg.time.created
+ const { id, sessionID, ...data } = msg
+ Database.use((db) => {
+ db.insert(MessageTable)
+ .values({
+ id,
+ session_id: sessionID,
+ time_created,
+ data,
+ })
+ .onConflictDoUpdate({ target: MessageTable.id, set: { data } })
+ .run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.Updated, {
+ info: msg,
+ }),
+ )
})
return msg
})
@@ -389,10 +606,15 @@ export namespace Session {
messageID: Identifier.schema("message"),
}),
async (input) => {
- await Storage.remove(["message", input.sessionID, input.messageID])
- Bus.publish(MessageV2.Event.Removed, {
- sessionID: input.sessionID,
- messageID: input.messageID,
+ // CASCADE delete handles parts automatically
+ Database.use((db) => {
+ db.delete(MessageTable).where(eq(MessageTable.id, input.messageID)).run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.Removed, {
+ sessionID: input.sessionID,
+ messageID: input.messageID,
+ }),
+ )
})
return input.messageID
},
@@ -405,39 +627,58 @@ export namespace Session {
partID: Identifier.schema("part"),
}),
async (input) => {
- await Storage.remove(["part", input.messageID, input.partID])
- Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: input.sessionID,
- messageID: input.messageID,
- partID: input.partID,
+ Database.use((db) => {
+ db.delete(PartTable).where(eq(PartTable.id, input.partID)).run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: input.sessionID,
+ messageID: input.messageID,
+ partID: input.partID,
+ }),
+ )
})
return input.partID
},
)
- const UpdatePartInput = z.union([
- MessageV2.Part,
- z.object({
- part: MessageV2.TextPart,
- delta: z.string(),
- }),
- z.object({
- part: MessageV2.ReasoningPart,
- delta: z.string(),
- }),
- ])
-
- export const updatePart = fn(UpdatePartInput, async (input) => {
- const part = "delta" in input ? input.part : input
- const delta = "delta" in input ? input.delta : undefined
- await Storage.write(["part", part.messageID, part.id], part)
- Bus.publish(MessageV2.Event.PartUpdated, {
- part,
- delta,
+ const UpdatePartInput = MessageV2.Part
+
+ export const updatePart = fn(UpdatePartInput, async (part) => {
+ const { id, messageID, sessionID, ...data } = part
+ const time = Date.now()
+ Database.use((db) => {
+ db.insert(PartTable)
+ .values({
+ id,
+ message_id: messageID,
+ session_id: sessionID,
+ time_created: time,
+ data,
+ })
+ .onConflictDoUpdate({ target: PartTable.id, set: { data } })
+ .run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.PartUpdated, {
+ part,
+ }),
+ )
})
return part
})
+ export const updatePartDelta = fn(
+ z.object({
+ sessionID: z.string(),
+ messageID: z.string(),
+ partID: z.string(),
+ field: z.string(),
+ delta: z.string(),
+ }),
+ async (input) => {
+ Bus.publish(MessageV2.Event.PartDelta, input)
+ },
+ )
+
export const getUsage = fn(
z.object({
model: z.custom(),
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 70763548c6a0..178751a2227a 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -6,6 +6,10 @@ import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
+import { Database, eq, desc, inArray } from "@/storage/db"
+import { MessageTable, PartTable } from "./session.sql"
+import { ProviderTransform } from "@/provider/transform"
+import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
@@ -456,7 +460,16 @@ export namespace MessageV2 {
"message.part.updated",
z.object({
part: Part,
- delta: z.string().optional(),
+ }),
+ ),
+ PartDelta: BusEvent.define(
+ "message.part.delta",
+ z.object({
+ sessionID: z.string(),
+ messageID: z.string(),
+ partID: z.string(),
+ field: z.string(),
+ delta: z.string(),
}),
),
PartRemoved: BusEvent.define(
@@ -701,23 +714,65 @@ export namespace MessageV2 {
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
- const list = await Array.fromAsync(await Storage.list(["message", sessionID]))
- for (let i = list.length - 1; i >= 0; i--) {
- yield await get({
- sessionID,
- messageID: list[i][2],
- })
+ const size = 50
+ let offset = 0
+ while (true) {
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(MessageTable)
+ .where(eq(MessageTable.session_id, sessionID))
+ .orderBy(desc(MessageTable.time_created))
+ .limit(size)
+ .offset(offset)
+ .all(),
+ )
+ if (rows.length === 0) break
+
+ const ids = rows.map((row) => row.id)
+ const partsByMessage = new Map()
+ if (ids.length > 0) {
+ const partRows = Database.use((db) =>
+ db
+ .select()
+ .from(PartTable)
+ .where(inArray(PartTable.message_id, ids))
+ .orderBy(PartTable.message_id, PartTable.id)
+ .all(),
+ )
+ for (const row of partRows) {
+ const part = {
+ ...row.data,
+ id: row.id,
+ sessionID: row.session_id,
+ messageID: row.message_id,
+ } as MessageV2.Part
+ const list = partsByMessage.get(row.message_id)
+ if (list) list.push(part)
+ else partsByMessage.set(row.message_id, [part])
+ }
+ }
+
+ for (const row of rows) {
+ const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
+ yield {
+ info,
+ parts: partsByMessage.get(row.id) ?? [],
+ }
+ }
+
+ offset += rows.length
+ if (rows.length < size) break
}
})
- export const parts = fn(Identifier.schema("message"), async (messageID) => {
- const result = [] as MessageV2.Part[]
- for (const item of await Storage.list(["part", messageID])) {
- const read = await Storage.read(item)
- result.push(read)
- }
- result.sort((a, b) => (a.id > b.id ? 1 : -1))
- return result
+ export const parts = fn(Identifier.schema("message"), async (message_id) => {
+ const rows = Database.use((db) =>
+ db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
+ )
+ return rows.map(
+ (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part,
+ )
})
export const get = fn(
@@ -726,8 +781,11 @@ export namespace MessageV2 {
messageID: Identifier.schema("message"),
}),
async (input): Promise => {
+ const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())
+ if (!row) throw new Error(`Message not found: ${input.messageID}`)
+ const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
return {
- info: await Storage.read(["message", input.sessionID, input.messageID]),
+ info,
parts: await parts(input.messageID),
}
},
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 8e7472e2f18c..e7532d20073b 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -63,17 +63,19 @@ export namespace SessionProcessor {
if (value.id in reasoningMap) {
continue
}
- reasoningMap[value.id] = {
+ const reasoningPart = {
id: Identifier.ascending("part"),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
- type: "reasoning",
+ type: "reasoning" as const,
text: "",
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
+ reasoningMap[value.id] = reasoningPart
+ await Session.updatePart(reasoningPart)
break
case "reasoning-delta":
@@ -81,7 +83,13 @@ export namespace SessionProcessor {
const part = reasoningMap[value.id]
part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata
- if (part.text) await Session.updatePart({ part, delta: value.text })
+ await Session.updatePartDelta({
+ sessionID: part.sessionID,
+ messageID: part.messageID,
+ partID: part.id,
+ field: "text",
+ delta: value.text,
+ })
}
break
@@ -288,17 +296,20 @@ export namespace SessionProcessor {
},
metadata: value.providerMetadata,
}
+ await Session.updatePart(currentText)
break
case "text-delta":
if (currentText) {
currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata
- if (currentText.text)
- await Session.updatePart({
- part: currentText,
- delta: value.text,
- })
+ await Session.updatePartDelta({
+ sessionID: currentText.sessionID,
+ messageID: currentText.messageID,
+ partID: currentText.id,
+ field: "text",
+ delta: value.text,
+ })
}
break
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 99d44cd850f7..f705f209aa9a 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -26,7 +26,6 @@ import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
-import { ListTool } from "../tool/ls"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -175,9 +174,7 @@ export namespace SessionPrompt {
}
if (permissions.length > 0) {
session.permission = permissions
- await Session.update(session.id, (draft) => {
- draft.permission = permissions
- })
+ await Session.setPermission({ sessionID: session.id, permission: permissions })
}
if (input.noReply === true) {
@@ -1198,7 +1195,7 @@ export namespace SessionPrompt {
}
if (part.mime === "application/x-directory") {
- const args = { path: filepath }
+ const args = { filePath: filepath }
const listCtx: Tool.Context = {
sessionID: input.sessionID,
abort: new AbortController().signal,
@@ -1209,7 +1206,7 @@ export namespace SessionPrompt {
metadata: async () => {},
ask: async () => {},
}
- const result = await ListTool.init().then((t) => t.execute(args, listCtx))
+ const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
return [
{
id: Identifier.ascending("part"),
@@ -1217,7 +1214,7 @@ export namespace SessionPrompt {
sessionID: input.sessionID,
type: "text",
synthetic: true,
- text: `Called the list tool with the following input: ${JSON.stringify(args)}`,
+ text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
{
id: Identifier.ascending("part"),
@@ -1947,21 +1944,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
],
})
const text = await result.text.catch((err) => log.error("failed to generate title", { error: err }))
- if (text)
- return Session.update(
- input.session.id,
- (draft) => {
- const cleaned = text
- .replace(/[\s\S]*?<\/think>\s*/g, "")
- .split("\n")
- .map((line) => line.trim())
- .find((line) => line.length > 0)
- if (!cleaned) return
-
- const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
- draft.title = title
- },
- { touch: false },
- )
+ if (text) {
+ const cleaned = text
+ .replace(/[\s\S]*?<\/think>\s*/g, "")
+ .split("\n")
+ .map((line) => line.trim())
+ .find((line) => line.length > 0)
+ if (!cleaned) return
+
+ const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
+ return Session.setTitle({ sessionID: input.session.id, title })
+ }
}
}
diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts
index 7afe44e2ce37..ef9c7e2aace9 100644
--- a/packages/opencode/src/session/revert.ts
+++ b/packages/opencode/src/session/revert.ts
@@ -4,8 +4,9 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2"
import { Session } from "."
import { Log } from "../util/log"
-import { splitWhen } from "remeda"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { MessageTable, PartTable } from "./session.sql"
+import { Storage } from "@/storage/storage"
import { Bus } from "../bus"
import { SessionPrompt } from "./prompt"
import { SessionSummary } from "./summary"
@@ -65,13 +66,14 @@ export namespace SessionRevert {
sessionID: input.sessionID,
diff: diffs,
})
- return Session.update(input.sessionID, (draft) => {
- draft.revert = revert
- draft.summary = {
+ return Session.setRevert({
+ sessionID: input.sessionID,
+ revert,
+ summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
- }
+ },
})
}
return session
@@ -83,39 +85,54 @@ export namespace SessionRevert {
const session = await Session.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
- const next = await Session.update(input.sessionID, (draft) => {
- draft.revert = undefined
- })
- return next
+ return Session.clearRevert(input.sessionID)
}
export async function cleanup(session: Session.Info) {
if (!session.revert) return
const sessionID = session.id
- let msgs = await Session.messages({ sessionID })
+ const msgs = await Session.messages({ sessionID })
const messageID = session.revert.messageID
- const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
- msgs = preserve
+ const preserve = [] as MessageV2.WithParts[]
+ const remove = [] as MessageV2.WithParts[]
+ let target: MessageV2.WithParts | undefined
+ for (const msg of msgs) {
+ if (msg.info.id < messageID) {
+ preserve.push(msg)
+ continue
+ }
+ if (msg.info.id > messageID) {
+ remove.push(msg)
+ continue
+ }
+ if (session.revert.partID) {
+ preserve.push(msg)
+ target = msg
+ continue
+ }
+ remove.push(msg)
+ }
for (const msg of remove) {
- await Storage.remove(["message", sessionID, msg.info.id])
+ Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
}
- const last = preserve.at(-1)
- if (session.revert.partID && last) {
+ if (session.revert.partID && target) {
const partID = session.revert.partID
- const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
- last.parts = preserveParts
- for (const part of removeParts) {
- await Storage.remove(["part", last.info.id, part.id])
- await Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: sessionID,
- messageID: last.info.id,
- partID: part.id,
- })
+ const removeStart = target.parts.findIndex((part) => part.id === partID)
+ if (removeStart >= 0) {
+ const preserveParts = target.parts.slice(0, removeStart)
+ const removeParts = target.parts.slice(removeStart)
+ target.parts = preserveParts
+ for (const part of removeParts) {
+ Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
+ await Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: sessionID,
+ messageID: target.info.id,
+ partID: part.id,
+ })
+ }
}
}
- await Session.update(sessionID, (draft) => {
- draft.revert = undefined
- })
+ await Session.clearRevert(sessionID)
}
}
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
new file mode 100644
index 000000000000..9c5c72c4c578
--- /dev/null
+++ b/packages/opencode/src/session/session.sql.ts
@@ -0,0 +1,88 @@
+import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
+import { ProjectTable } from "../project/project.sql"
+import type { MessageV2 } from "./message-v2"
+import type { Snapshot } from "@/snapshot"
+import type { PermissionNext } from "@/permission/next"
+import { Timestamps } from "@/storage/schema.sql"
+
+type PartData = Omit
+type InfoData = Omit
+
+export const SessionTable = sqliteTable(
+ "session",
+ {
+ id: text().primaryKey(),
+ project_id: text()
+ .notNull()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+ parent_id: text(),
+ slug: text().notNull(),
+ directory: text().notNull(),
+ title: text().notNull(),
+ version: text().notNull(),
+ share_url: text(),
+ summary_additions: integer(),
+ summary_deletions: integer(),
+ summary_files: integer(),
+ summary_diffs: text({ mode: "json" }).$type(),
+ revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
+ permission: text({ mode: "json" }).$type(),
+ ...Timestamps,
+ time_compacting: integer(),
+ time_archived: integer(),
+ },
+ (table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)],
+)
+
+export const MessageTable = sqliteTable(
+ "message",
+ {
+ id: text().primaryKey(),
+ session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type(),
+ },
+ (table) => [index("message_session_idx").on(table.session_id)],
+)
+
+export const PartTable = sqliteTable(
+ "part",
+ {
+ id: text().primaryKey(),
+ message_id: text()
+ .notNull()
+ .references(() => MessageTable.id, { onDelete: "cascade" }),
+ session_id: text().notNull(),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type(),
+ },
+ (table) => [index("part_message_idx").on(table.message_id), index("part_session_idx").on(table.session_id)],
+)
+
+export const TodoTable = sqliteTable(
+ "todo",
+ {
+ session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ content: text().notNull(),
+ status: text().notNull(),
+ priority: text().notNull(),
+ position: integer().notNull(),
+ ...Timestamps,
+ },
+ (table) => [
+ primaryKey({ columns: [table.session_id, table.position] }),
+ index("todo_session_idx").on(table.session_id),
+ ],
+)
+
+export const PermissionTable = sqliteTable("permission", {
+ project_id: text()
+ .primaryKey()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type(),
+})
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index b92fc9979f22..c3e14ddd691f 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/packages/opencode/src/session/summary.ts
@@ -90,12 +90,13 @@ export namespace SessionSummary {
async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
const diffs = await computeDiff({ messages: input.messages })
- await Session.update(input.sessionID, (draft) => {
- draft.summary = {
+ await Session.setSummary({
+ sessionID: input.sessionID,
+ summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
- }
+ },
})
await Storage.write(["session_diff", input.sessionID], diffs)
Bus.publish(Session.Event.Diff, {
diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts
index aa7df7e981ac..ec2bcdda3c41 100644
--- a/packages/opencode/src/session/todo.ts
+++ b/packages/opencode/src/session/todo.ts
@@ -1,7 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
-import { Storage } from "../storage/storage"
+import { Database, eq, asc } from "../storage/db"
+import { TodoTable } from "./session.sql"
export namespace Todo {
export const Info = z
@@ -9,7 +10,6 @@ export namespace Todo {
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
- id: z.string().describe("Unique identifier for the todo item"),
})
.meta({ ref: "Todo" })
export type Info = z.infer
@@ -24,14 +24,33 @@ export namespace Todo {
),
}
- export async function update(input: { sessionID: string; todos: Info[] }) {
- await Storage.write(["todo", input.sessionID], input.todos)
+ export function update(input: { sessionID: string; todos: Info[] }) {
+ Database.transaction((db) => {
+ db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
+ if (input.todos.length === 0) return
+ db.insert(TodoTable)
+ .values(
+ input.todos.map((todo, position) => ({
+ session_id: input.sessionID,
+ content: todo.content,
+ status: todo.status,
+ priority: todo.priority,
+ position,
+ })),
+ )
+ .run()
+ })
Bus.publish(Event.Updated, input)
}
- export async function get(sessionID: string) {
- return Storage.read(["todo", sessionID])
- .then((x) => x || [])
- .catch(() => [])
+ export function get(sessionID: string) {
+ const rows = Database.use((db) =>
+ db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
+ )
+ return rows.map((row) => ({
+ content: row.content,
+ status: row.status,
+ priority: row.priority,
+ }))
}
}
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index a3a229d1a1d5..c36616b7ef9d 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -4,7 +4,8 @@ import { ulid } from "ulid"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
-import { Storage } from "@/storage/storage"
+import { Database, eq } from "@/storage/db"
+import { SessionShareTable } from "./share.sql"
import { Log } from "@/util/log"
import type * as SDK from "@opencode-ai/sdk/v2"
@@ -77,17 +78,26 @@ export namespace ShareNext {
})
.then((x) => x.json())
.then((x) => x as { id: string; url: string; secret: string })
- await Storage.write(["session_share", sessionID], result)
+ Database.use((db) =>
+ db
+ .insert(SessionShareTable)
+ .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
+ .onConflictDoUpdate({
+ target: SessionShareTable.session_id,
+ set: { id: result.id, secret: result.secret, url: result.url },
+ })
+ .run(),
+ )
fullSync(sessionID)
return result
}
function get(sessionID: string) {
- return Storage.read<{
- id: string
- secret: string
- url: string
- }>(["session_share", sessionID])
+ const row = Database.use((db) =>
+ db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
+ )
+ if (!row) return
+ return { id: row.id, secret: row.secret, url: row.url }
}
type Data =
@@ -132,7 +142,7 @@ export namespace ShareNext {
const queued = queue.get(sessionID)
if (!queued) return
queue.delete(sessionID)
- const share = await get(sessionID).catch(() => undefined)
+ const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, {
@@ -152,7 +162,7 @@ export namespace ShareNext {
export async function remove(sessionID: string) {
if (disabled) return
log.info("removing share", { sessionID })
- const share = await get(sessionID)
+ const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, {
method: "DELETE",
@@ -163,7 +173,7 @@ export namespace ShareNext {
secret: share.secret,
}),
})
- await Storage.remove(["session_share", sessionID])
+ Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
}
async function fullSync(sessionID: string) {
diff --git a/packages/opencode/src/share/share.sql.ts b/packages/opencode/src/share/share.sql.ts
new file mode 100644
index 000000000000..268d41a6f618
--- /dev/null
+++ b/packages/opencode/src/share/share.sql.ts
@@ -0,0 +1,13 @@
+import { sqliteTable, text } from "drizzle-orm/sqlite-core"
+import { SessionTable } from "../session/session.sql"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const SessionShareTable = sqliteTable("session_share", {
+ session_id: text()
+ .primaryKey()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ id: text().notNull(),
+ secret: text().notNull(),
+ url: text().notNull(),
+ ...Timestamps,
+})
diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts
deleted file mode 100644
index f7bf4b3fa52a..000000000000
--- a/packages/opencode/src/share/share.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Bus } from "../bus"
-import { Installation } from "../installation"
-import { Session } from "../session"
-import { MessageV2 } from "../session/message-v2"
-import { Log } from "../util/log"
-
-export namespace Share {
- const log = Log.create({ service: "share" })
-
- let queue: Promise = Promise.resolve()
- const pending = new Map()
-
- export async function sync(key: string, content: any) {
- if (disabled) return
- const [root, ...splits] = key.split("/")
- if (root !== "session") return
- const [sub, sessionID] = splits
- if (sub === "share") return
- const share = await Session.getShare(sessionID).catch(() => {})
- if (!share) return
- const { secret } = share
- pending.set(key, content)
- queue = queue
- .then(async () => {
- const content = pending.get(key)
- if (content === undefined) return
- pending.delete(key)
-
- return fetch(`${URL}/share_sync`, {
- method: "POST",
- body: JSON.stringify({
- sessionID: sessionID,
- secret,
- key: key,
- content,
- }),
- })
- })
- .then((x) => {
- if (x) {
- log.info("synced", {
- key: key,
- status: x.status,
- })
- }
- })
- }
-
- export function init() {
- Bus.subscribe(Session.Event.Updated, async (evt) => {
- await sync("session/info/" + evt.properties.info.id, evt.properties.info)
- })
- Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
- await sync("session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id, evt.properties.info)
- })
- Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
- await sync(
- "session/part/" +
- evt.properties.part.sessionID +
- "/" +
- evt.properties.part.messageID +
- "/" +
- evt.properties.part.id,
- evt.properties.part,
- )
- })
- }
-
- export const URL =
- process.env["OPENCODE_API"] ??
- (Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
-
- const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
-
- export async function create(sessionID: string) {
- if (disabled) return { url: "", secret: "" }
- return fetch(`${URL}/share_create`, {
- method: "POST",
- body: JSON.stringify({ sessionID: sessionID }),
- })
- .then((x) => x.json())
- .then((x) => x as { url: string; secret: string })
- }
-
- export async function remove(sessionID: string, secret: string) {
- if (disabled) return {}
- return fetch(`${URL}/share_delete`, {
- method: "POST",
- body: JSON.stringify({ sessionID, secret }),
- }).then((x) => x.json())
- }
-}
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 42795b7ebcc3..9b37d46fee61 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -178,6 +178,10 @@ export namespace Skill {
return state().then((x) => x.skills[name])
}
+ export async function reset() {
+ await state.reset()
+ }
+
export async function all() {
return state().then((x) => Object.values(x.skills))
}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index b3c8a905c252..a1c2b57812e8 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -2,6 +2,7 @@ import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Log } from "../util/log"
+import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
@@ -23,7 +24,7 @@ export namespace Snapshot {
}
export async function cleanup() {
- if (Instance.project.vcs !== "git") return
+ if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
@@ -48,7 +49,7 @@ export namespace Snapshot {
}
export async function track() {
- if (Instance.project.vcs !== "git") return
+ if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
diff --git a/packages/opencode/src/sql.d.ts b/packages/opencode/src/sql.d.ts
new file mode 100644
index 000000000000..28cb1e26492d
--- /dev/null
+++ b/packages/opencode/src/sql.d.ts
@@ -0,0 +1,4 @@
+declare module "*.sql" {
+ const content: string
+ export default content
+}
diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts
new file mode 100644
index 000000000000..0974cbe7be44
--- /dev/null
+++ b/packages/opencode/src/storage/db.ts
@@ -0,0 +1,142 @@
+import { Database as BunDatabase } from "bun:sqlite"
+import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
+import { migrate } from "drizzle-orm/bun-sqlite/migrator"
+import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
+export * from "drizzle-orm"
+import { Context } from "../util/context"
+import { lazy } from "../util/lazy"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { NamedError } from "@opencode-ai/util/error"
+import z from "zod"
+import path from "path"
+import { readFileSync, readdirSync } from "fs"
+import * as schema from "./schema"
+
+declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
+
+export const NotFoundError = NamedError.create(
+ "NotFoundError",
+ z.object({
+ message: z.string(),
+ }),
+)
+
+const log = Log.create({ service: "db" })
+
+export namespace Database {
+ export const Path = path.join(Global.Path.data, "opencode.db")
+ type Schema = typeof schema
+ export type Transaction = SQLiteTransaction<"sync", void, Schema>
+
+ type Client = SQLiteBunDatabase
+
+ type Journal = { sql: string; timestamp: number }[]
+
+ function time(tag: string) {
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
+ if (!match) return 0
+ return Date.UTC(
+ Number(match[1]),
+ Number(match[2]) - 1,
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6]),
+ )
+ }
+
+ function migrations(dir: string): Journal {
+ const dirs = readdirSync(dir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+
+ const sql = dirs
+ .map((name) => {
+ const file = path.join(dir, name, "migration.sql")
+ if (!Bun.file(file).size) return
+ return {
+ sql: readFileSync(file, "utf-8"),
+ timestamp: time(name),
+ }
+ })
+ .filter(Boolean) as Journal
+
+ return sql.sort((a, b) => a.timestamp - b.timestamp)
+ }
+
+ export const Client = lazy(() => {
+ log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
+
+ const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
+
+ sqlite.run("PRAGMA journal_mode = WAL")
+ sqlite.run("PRAGMA synchronous = NORMAL")
+ sqlite.run("PRAGMA busy_timeout = 5000")
+ sqlite.run("PRAGMA cache_size = -64000")
+ sqlite.run("PRAGMA foreign_keys = ON")
+ sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
+
+ const db = drizzle({ client: sqlite, schema })
+
+ // Apply schema migrations
+ const entries =
+ typeof OPENCODE_MIGRATIONS !== "undefined"
+ ? OPENCODE_MIGRATIONS
+ : migrations(path.join(import.meta.dirname, "../../migration"))
+ if (entries.length > 0) {
+ log.info("applying migrations", {
+ count: entries.length,
+ mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev",
+ })
+ migrate(db, entries)
+ }
+
+ return db
+ })
+
+ export type TxOrDb = Transaction | Client
+
+ const ctx = Context.create<{
+ tx: TxOrDb
+ effects: (() => void | Promise)[]
+ }>("database")
+
+ export function use(callback: (trx: TxOrDb) => T): T {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise)[] = []
+ const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
+ for (const effect of effects) effect()
+ return result
+ }
+ throw err
+ }
+ }
+
+ export function effect(fn: () => any | Promise) {
+ try {
+ ctx.use().effects.push(fn)
+ } catch {
+ fn()
+ }
+ }
+
+ export function transaction(callback: (tx: TxOrDb) => T): T {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise)[] = []
+ const result = Client().transaction((tx) => {
+ return ctx.provide({ tx, effects }, () => callback(tx))
+ })
+ for (const effect of effects) effect()
+ return result
+ }
+ throw err
+ }
+ }
+}
diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts
new file mode 100644
index 000000000000..e0684ce3c199
--- /dev/null
+++ b/packages/opencode/src/storage/json-migration.ts
@@ -0,0 +1,428 @@
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { ProjectTable } from "../project/project.sql"
+import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
+import { SessionShareTable } from "../share/share.sql"
+import path from "path"
+import { existsSync } from "fs"
+
+export namespace JsonMigration {
+ const log = Log.create({ service: "json-migration" })
+
+ export type Progress = {
+ current: number
+ total: number
+ label: string
+ }
+
+ type Options = {
+ progress?: (event: Progress) => void
+ }
+
+ export async function run(sqlite: Database, options?: Options) {
+ const storageDir = path.join(Global.Path.data, "storage")
+
+ if (!existsSync(storageDir)) {
+ log.info("storage directory does not exist, skipping migration")
+ return {
+ projects: 0,
+ sessions: 0,
+ messages: 0,
+ parts: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ errors: [] as string[],
+ }
+ }
+
+ log.info("starting json to sqlite migration", { storageDir })
+ const start = performance.now()
+
+ const db = drizzle({ client: sqlite })
+
+ // Optimize SQLite for bulk inserts
+ sqlite.exec("PRAGMA journal_mode = WAL")
+ sqlite.exec("PRAGMA synchronous = OFF")
+ sqlite.exec("PRAGMA cache_size = 10000")
+ sqlite.exec("PRAGMA temp_store = MEMORY")
+ const stats = {
+ projects: 0,
+ sessions: 0,
+ messages: 0,
+ parts: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ errors: [] as string[],
+ }
+ const orphans = {
+ sessions: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ }
+ const errs = stats.errors
+
+ const batchSize = 1000
+ const now = Date.now()
+
+ async function list(pattern: string) {
+ const items: string[] = []
+ const scan = new Bun.Glob(pattern)
+ for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
+ items.push(file)
+ }
+ return items
+ }
+
+ async function read(files: string[], start: number, end: number) {
+ const count = end - start
+ const tasks = new Array(count)
+ for (let i = 0; i < count; i++) {
+ tasks[i] = Bun.file(files[start + i]).json()
+ }
+ const results = await Promise.allSettled(tasks)
+ const items = new Array(count)
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i]
+ if (result.status === "fulfilled") {
+ items[i] = result.value
+ continue
+ }
+ errs.push(`failed to read ${files[start + i]}: ${result.reason}`)
+ }
+ return items
+ }
+
+ function insert(values: any[], table: any, label: string) {
+ if (values.length === 0) return 0
+ try {
+ db.insert(table).values(values).onConflictDoNothing().run()
+ return values.length
+ } catch (e) {
+ errs.push(`failed to migrate ${label} batch: ${e}`)
+ return 0
+ }
+ }
+
+ // Pre-scan all files upfront to avoid repeated glob operations
+ log.info("scanning files...")
+ const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
+ list("project/*.json"),
+ list("session/*/*.json"),
+ list("message/*/*.json"),
+ list("part/*/*.json"),
+ list("todo/*.json"),
+ list("permission/*.json"),
+ list("session_share/*.json"),
+ ])
+
+ log.info("file scan complete", {
+ projects: projectFiles.length,
+ sessions: sessionFiles.length,
+ messages: messageFiles.length,
+ parts: partFiles.length,
+ todos: todoFiles.length,
+ permissions: permFiles.length,
+ shares: shareFiles.length,
+ })
+
+ const total = Math.max(
+ 1,
+ projectFiles.length +
+ sessionFiles.length +
+ messageFiles.length +
+ partFiles.length +
+ todoFiles.length +
+ permFiles.length +
+ shareFiles.length,
+ )
+ const progress = options?.progress
+ let current = 0
+ const step = (label: string, count: number) => {
+ current = Math.min(total, current + count)
+ progress?.({ current, total, label })
+ }
+
+ progress?.({ current, total, label: "starting" })
+
+ sqlite.exec("BEGIN TRANSACTION")
+
+ // Migrate projects first (no FK deps)
+ // Derive all IDs from file paths, not JSON content
+ const projectIds = new Set()
+ const projectValues = [] as any[]
+ for (let i = 0; i < projectFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, projectFiles.length)
+ const batch = await read(projectFiles, i, end)
+ projectValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const id = path.basename(projectFiles[i + j], ".json")
+ projectIds.add(id)
+ projectValues.push({
+ id,
+ worktree: data.worktree ?? "/",
+ vcs: data.vcs,
+ name: data.name ?? undefined,
+ icon_url: data.icon?.url,
+ icon_color: data.icon?.color,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ time_initialized: data.time?.initialized,
+ sandboxes: data.sandboxes ?? [],
+ commands: data.commands,
+ })
+ }
+ stats.projects += insert(projectValues, ProjectTable, "project")
+ step("projects", end - i)
+ }
+ log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
+
+ // Migrate sessions (depends on projects)
+ // Derive all IDs from directory/file paths, not JSON content, since earlier
+ // migrations may have moved sessions to new directories without updating the JSON
+ const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
+ const sessionIds = new Set()
+ const sessionValues = [] as any[]
+ for (let i = 0; i < sessionFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, sessionFiles.length)
+ const batch = await read(sessionFiles, i, end)
+ sessionValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const id = path.basename(sessionFiles[i + j], ".json")
+ const projectID = sessionProjects[i + j]
+ if (!projectIds.has(projectID)) {
+ orphans.sessions++
+ continue
+ }
+ sessionIds.add(id)
+ sessionValues.push({
+ id,
+ project_id: projectID,
+ parent_id: data.parentID ?? null,
+ slug: data.slug ?? "",
+ directory: data.directory ?? "",
+ title: data.title ?? "",
+ version: data.version ?? "",
+ share_url: data.share?.url ?? null,
+ summary_additions: data.summary?.additions ?? null,
+ summary_deletions: data.summary?.deletions ?? null,
+ summary_files: data.summary?.files ?? null,
+ summary_diffs: data.summary?.diffs ?? null,
+ revert: data.revert ?? null,
+ permission: data.permission ?? null,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ time_compacting: data.time?.compacting ?? null,
+ time_archived: data.time?.archived ?? null,
+ })
+ }
+ stats.sessions += insert(sessionValues, SessionTable, "session")
+ step("sessions", end - i)
+ }
+ log.info("migrated sessions", { count: stats.sessions })
+ if (orphans.sessions > 0) {
+ log.warn("skipped orphaned sessions", { count: orphans.sessions })
+ }
+
+ // Migrate messages using pre-scanned file map
+ const allMessageFiles = [] as string[]
+ const allMessageSessions = [] as string[]
+ const messageSessions = new Map()
+ for (const file of messageFiles) {
+ const sessionID = path.basename(path.dirname(file))
+ if (!sessionIds.has(sessionID)) continue
+ allMessageFiles.push(file)
+ allMessageSessions.push(sessionID)
+ }
+
+ for (let i = 0; i < allMessageFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, allMessageFiles.length)
+ const batch = await read(allMessageFiles, i, end)
+ const values = new Array(batch.length)
+ let count = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const file = allMessageFiles[i + j]
+ const id = path.basename(file, ".json")
+ const sessionID = allMessageSessions[i + j]
+ messageSessions.set(id, sessionID)
+ const rest = data
+ delete rest.id
+ delete rest.sessionID
+ values[count++] = {
+ id,
+ session_id: sessionID,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ data: rest,
+ }
+ }
+ values.length = count
+ stats.messages += insert(values, MessageTable, "message")
+ step("messages", end - i)
+ }
+ log.info("migrated messages", { count: stats.messages })
+
+ // Migrate parts using pre-scanned file map
+ for (let i = 0; i < partFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, partFiles.length)
+ const batch = await read(partFiles, i, end)
+ const values = new Array(batch.length)
+ let count = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const file = partFiles[i + j]
+ const id = path.basename(file, ".json")
+ const messageID = path.basename(path.dirname(file))
+ const sessionID = messageSessions.get(messageID)
+ if (!sessionID) {
+ errs.push(`part missing message session: ${file}`)
+ continue
+ }
+ if (!sessionIds.has(sessionID)) continue
+ const rest = data
+ delete rest.id
+ delete rest.messageID
+ delete rest.sessionID
+ values[count++] = {
+ id,
+ message_id: messageID,
+ session_id: sessionID,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ data: rest,
+ }
+ }
+ values.length = count
+ stats.parts += insert(values, PartTable, "part")
+ step("parts", end - i)
+ }
+ log.info("migrated parts", { count: stats.parts })
+
+ // Migrate todos
+ const todoSessions = todoFiles.map((file) => path.basename(file, ".json"))
+ for (let i = 0; i < todoFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, todoFiles.length)
+ const batch = await read(todoFiles, i, end)
+ const values = [] as any[]
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const sessionID = todoSessions[i + j]
+ if (!sessionIds.has(sessionID)) {
+ orphans.todos++
+ continue
+ }
+ if (!Array.isArray(data)) {
+ errs.push(`todo not an array: ${todoFiles[i + j]}`)
+ continue
+ }
+ for (let position = 0; position < data.length; position++) {
+ const todo = data[position]
+ if (!todo?.content || !todo?.status || !todo?.priority) continue
+ values.push({
+ session_id: sessionID,
+ content: todo.content,
+ status: todo.status,
+ priority: todo.priority,
+ position,
+ time_created: now,
+ time_updated: now,
+ })
+ }
+ }
+ stats.todos += insert(values, TodoTable, "todo")
+ step("todos", end - i)
+ }
+ log.info("migrated todos", { count: stats.todos })
+ if (orphans.todos > 0) {
+ log.warn("skipped orphaned todos", { count: orphans.todos })
+ }
+
+ // Migrate permissions
+ const permProjects = permFiles.map((file) => path.basename(file, ".json"))
+ const permValues = [] as any[]
+ for (let i = 0; i < permFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, permFiles.length)
+ const batch = await read(permFiles, i, end)
+ permValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const projectID = permProjects[i + j]
+ if (!projectIds.has(projectID)) {
+ orphans.permissions++
+ continue
+ }
+ permValues.push({ project_id: projectID, data })
+ }
+ stats.permissions += insert(permValues, PermissionTable, "permission")
+ step("permissions", end - i)
+ }
+ log.info("migrated permissions", { count: stats.permissions })
+ if (orphans.permissions > 0) {
+ log.warn("skipped orphaned permissions", { count: orphans.permissions })
+ }
+
+ // Migrate session shares
+ const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
+ const shareValues = [] as any[]
+ for (let i = 0; i < shareFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, shareFiles.length)
+ const batch = await read(shareFiles, i, end)
+ shareValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const sessionID = shareSessions[i + j]
+ if (!sessionIds.has(sessionID)) {
+ orphans.shares++
+ continue
+ }
+ if (!data?.id || !data?.secret || !data?.url) {
+ errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
+ continue
+ }
+ shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
+ }
+ stats.shares += insert(shareValues, SessionShareTable, "session_share")
+ step("shares", end - i)
+ }
+ log.info("migrated session shares", { count: stats.shares })
+ if (orphans.shares > 0) {
+ log.warn("skipped orphaned session shares", { count: orphans.shares })
+ }
+
+ sqlite.exec("COMMIT")
+
+ log.info("json migration complete", {
+ projects: stats.projects,
+ sessions: stats.sessions,
+ messages: stats.messages,
+ parts: stats.parts,
+ todos: stats.todos,
+ permissions: stats.permissions,
+ shares: stats.shares,
+ errorCount: stats.errors.length,
+ duration: Math.round(performance.now() - start),
+ })
+
+ if (stats.errors.length > 0) {
+ log.warn("migration errors", { errors: stats.errors.slice(0, 20) })
+ }
+
+ progress?.({ current: total, total, label: "complete" })
+
+ return stats
+ }
+}
diff --git a/packages/opencode/src/storage/schema.sql.ts b/packages/opencode/src/storage/schema.sql.ts
new file mode 100644
index 000000000000..ead3518dee04
--- /dev/null
+++ b/packages/opencode/src/storage/schema.sql.ts
@@ -0,0 +1,10 @@
+import { integer } from "drizzle-orm/sqlite-core"
+
+export const Timestamps = {
+ time_created: integer()
+ .notNull()
+ .$default(() => Date.now()),
+ time_updated: integer()
+ .notNull()
+ .$onUpdate(() => Date.now()),
+}
diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts
new file mode 100644
index 000000000000..7961b0e3804b
--- /dev/null
+++ b/packages/opencode/src/storage/schema.ts
@@ -0,0 +1,4 @@
+export { ControlAccountTable } from "../control/control.sql"
+export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
+export { SessionShareTable } from "../share/share.sql"
+export { ProjectTable } from "../project/project.sql"
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 5ed5a879b484..bb451b92fd5c 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -57,6 +57,10 @@ export namespace ToolRegistry {
return { custom }
})
+ export async function reset() {
+ await state.reset()
+ }
+
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
@@ -149,9 +153,17 @@ export namespace ToolRegistry {
})
.map(async (t) => {
using _ = log.time(t.id)
+ const tool = await t.init({ agent })
+ const output = {
+ description: tool.description,
+ parameters: tool.parameters,
+ }
+ await Plugin.trigger("tool.definition", { toolID: t.id }, output)
return {
id: t.id,
- ...(await t.init({ agent })),
+ ...tool,
+ description: output.description,
+ parameters: output.parameters,
}
}),
)
diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts
index beedd9c7cb3f..bf16428dfb3c 100644
--- a/packages/opencode/src/tool/websearch.ts
+++ b/packages/opencode/src/tool/websearch.ts
@@ -40,7 +40,7 @@ interface McpSearchResponse {
export const WebSearchTool = Tool.define("websearch", async () => {
return {
get description() {
- return DESCRIPTION.replace("{{date}}", new Date().toISOString().slice(0, 10))
+ return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: z.object({
query: z.string().describe("Websearch query"),
diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt
index 657ac745867b..551c0f3b593b 100644
--- a/packages/opencode/src/tool/websearch.txt
+++ b/packages/opencode/src/tool/websearch.txt
@@ -10,5 +10,5 @@ Usage notes:
- Configurable context length for optimal LLM integration
- Domain filtering and advanced search options available
-Today's date is {{date}}. You MUST use this year when searching for recent information or current events
-- Example: If today is 2025-07-15 and the user asks for "latest AI news", search for "AI news 2025", NOT "AI news 2024"
+The current year is {{year}}. You MUST use this year when searching for recent information or current events
+- Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025"
diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts
new file mode 100644
index 000000000000..201def36a8c6
--- /dev/null
+++ b/packages/opencode/src/util/git.ts
@@ -0,0 +1,64 @@
+import { $ } from "bun"
+import { Flag } from "../flag/flag"
+
+export interface GitResult {
+ exitCode: number
+ text(): string | Promise
+ stdout: Buffer | ReadableStream
+ stderr: Buffer | ReadableStream
+}
+
+/**
+ * Run a git command.
+ *
+ * Uses Bun's lightweight `$` shell by default. When the process is running
+ * as an ACP client, child processes inherit the parent's stdin pipe which
+ * carries protocol data – on Windows this causes git to deadlock. In that
+ * case we fall back to `Bun.spawn` with `stdin: "ignore"`.
+ */
+export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise {
+ if (Flag.OPENCODE_CLIENT === "acp") {
+ try {
+ const proc = Bun.spawn(["git", ...args], {
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ cwd: opts.cwd,
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
+ })
+ // Read output concurrently with exit to avoid pipe buffer deadlock
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).arrayBuffer(),
+ new Response(proc.stderr).arrayBuffer(),
+ ])
+ const stdoutBuf = Buffer.from(stdout)
+ const stderrBuf = Buffer.from(stderr)
+ return {
+ exitCode,
+ text: () => stdoutBuf.toString(),
+ stdout: stdoutBuf,
+ stderr: stderrBuf,
+ }
+ } catch (error) {
+ const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
+ return {
+ exitCode: 1,
+ text: () => "",
+ stdout: Buffer.alloc(0),
+ stderr,
+ }
+ }
+ }
+
+ const env = opts.env ? { ...process.env, ...opts.env } : undefined
+ let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
+ if (env) cmd = cmd.env(env)
+ const result = await cmd
+ return {
+ exitCode: result.exitCode,
+ text: () => result.text(),
+ stdout: result.stdout,
+ stderr: result.stderr,
+ }
+}
diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts
index 0cc6d8d5c456..55643dc6a722 100644
--- a/packages/opencode/src/util/lazy.ts
+++ b/packages/opencode/src/util/lazy.ts
@@ -4,9 +4,14 @@ export function lazy(fn: () => T) {
const result = (): T => {
if (loaded) return value as T
- loaded = true
- value = fn()
- return value as T
+ try {
+ value = fn()
+ loaded = true
+ return value as T
+ } catch (e) {
+ // Don't mark as loaded if initialization failed
+ throw e
+ }
}
result.reset = () => {
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 88c778cbb824..d85a0843fbaf 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -7,7 +7,8 @@ import { Global } from "../global"
import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "../project/project"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { BusEvent } from "@/bus/bus-event"
@@ -307,7 +308,8 @@ export namespace Worktree {
}
async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
- const project = await Storage.read(["project", input.projectID]).catch(() => undefined)
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
+ const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project")
if (!ok) return false
@@ -420,49 +422,78 @@ export namespace Worktree {
}
const directory = await canonical(input.directory)
+ const locate = async (stdout: Uint8Array | undefined) => {
+ const lines = outputText(stdout)
+ .split("\n")
+ .map((line) => line.trim())
+ const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
+ if (!line) return acc
+ if (line.startsWith("worktree ")) {
+ acc.push({ path: line.slice("worktree ".length).trim() })
+ return acc
+ }
+ const current = acc[acc.length - 1]
+ if (!current) return acc
+ if (line.startsWith("branch ")) {
+ current.branch = line.slice("branch ".length).trim()
+ }
+ return acc
+ }, [])
+
+ return (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
+ }
+
+ const clean = (target: string) =>
+ fs
+ .rm(target, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 100,
+ })
+ .catch((error) => {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
+ })
+
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
- const lines = outputText(list.stdout)
- .split("\n")
- .map((line) => line.trim())
- const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
- if (!line) return acc
- if (line.startsWith("worktree ")) {
- acc.push({ path: line.slice("worktree ".length).trim() })
- return acc
- }
- const current = acc[acc.length - 1]
- if (!current) return acc
- if (line.startsWith("branch ")) {
- current.branch = line.slice("branch ".length).trim()
- }
- return acc
- }, [])
-
- const entry = await (async () => {
- for (const item of entries) {
- if (!item.path) continue
- const key = await canonical(item.path)
- if (key === directory) return item
- }
- })()
+ const entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
- await fs.rm(directory, { recursive: true, force: true })
+ await clean(directory)
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) {
- throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
+ const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+ if (next.exitCode !== 0) {
+ throw new RemoveFailedError({
+ message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
+ })
+ }
+
+ const stale = await locate(next.stdout)
+ if (stale?.path) {
+ throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
+ }
}
+ await clean(entry.path)
+
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts
index 8e139ff59732..1145a1357d2d 100644
--- a/packages/opencode/test/acp/event-subscription.test.ts
+++ b/packages/opencode/test/acp/event-subscription.test.ts
@@ -122,12 +122,20 @@ function createFakeAgent() {
messages: async () => {
return { data: [] }
},
- message: async () => {
+ message: async (params?: any) => {
+ // Return a message with parts that can be looked up by partID
return {
data: {
info: {
role: "assistant",
},
+ parts: [
+ {
+ id: params?.messageID ? `${params.messageID}_part` : "part_1",
+ type: "text",
+ text: "",
+ },
+ ],
},
}
},
@@ -193,7 +201,7 @@ function createFakeAgent() {
}
describe("acp.agent event subscription", () => {
- test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => {
+ test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -207,14 +215,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionB,
- messageID: "msg_1",
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionB,
+ messageID: "msg_1",
+ partID: "msg_1_part",
+ field: "text",
delta: "hello",
},
},
@@ -230,7 +236,7 @@ describe("acp.agent event subscription", () => {
})
})
- test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => {
+ test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -248,14 +254,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionId,
- messageID,
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionId,
+ messageID,
+ partID: `${messageID}_part`,
+ field: "text",
delta,
},
},
@@ -402,14 +406,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionB,
- messageID: "msg_b",
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionB,
+ messageID: "msg_b",
+ partID: "msg_b_part",
+ field: "text",
delta: "session_b_message",
},
},
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 29f1efa40195..add3332048c8 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -2,7 +2,6 @@ import { test, expect } from "bun:test"
import os from "os"
import { PermissionNext } from "../../src/permission/next"
import { Instance } from "../../src/project/instance"
-import { Storage } from "../../src/storage/storage"
import { tmpdir } from "../fixture/fixture"
// fromConfig tests
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index f0d0272affdd..dee7045707ea 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -6,11 +6,19 @@ import fs from "fs/promises"
import fsSync from "fs"
import { afterAll } from "bun:test"
+// Set XDG env vars FIRST, before any src/ imports
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
await fs.mkdir(dir, { recursive: true })
afterAll(() => {
fsSync.rmSync(dir, { recursive: true, force: true })
})
+
+process.env["XDG_DATA_HOME"] = path.join(dir, "share")
+process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
+process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
+process.env["XDG_STATE_HOME"] = path.join(dir, "state")
+process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
+
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
const testHome = path.join(dir, "home")
@@ -21,12 +29,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome
const testManagedConfigDir = path.join(dir, "managed")
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
-process.env["XDG_DATA_HOME"] = path.join(dir, "share")
-process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
-process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
-process.env["XDG_STATE_HOME"] = path.join(dir, "state")
-process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
-
// Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
diff --git a/packages/opencode/test/project/hotreload.test.ts b/packages/opencode/test/project/hotreload.test.ts
new file mode 100644
index 000000000000..025bcdbfbf28
--- /dev/null
+++ b/packages/opencode/test/project/hotreload.test.ts
@@ -0,0 +1,38 @@
+import { expect, test } from "bun:test"
+import { HotReload } from "../../src/project/hotreload"
+
+const root = "/tmp/openwork-hotreload"
+
+test("matches project config files", () => {
+ expect(HotReload.classify(root, `${root}/opencode.json`)).toBe("opencode.json")
+ expect(HotReload.classify(root, `${root}/opencode.jsonc`)).toBe("opencode.jsonc")
+ expect(HotReload.classify(root, `${root}/AGENTS.md`)).toBe("AGENTS.md")
+})
+
+test("matches opencode directories", () => {
+ expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md`)).toBe(
+ ".opencode/skills/new-skill/SKILL.md",
+ )
+ expect(HotReload.classify(root, `${root}/.opencode/commands/fix.md`)).toBe(
+ ".opencode/commands/fix.md",
+ )
+ expect(HotReload.classify(root, `${root}/.opencode/plugins/example.ts`)).toBe(
+ ".opencode/plugins/example.ts",
+ )
+})
+
+test("ignores metadata, temp files, and unrelated files", () => {
+ expect(HotReload.classify(root, `${root}/README.md`)).toBeUndefined()
+ expect(HotReload.classify(root, `${root}/.opencode/openwork/openwork.json`)).toBeUndefined()
+ expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md.swp`)).toBeUndefined()
+ expect(HotReload.classify(root, `${root}/.git/HEAD`)).toBeUndefined()
+ expect(HotReload.classify(root, `/tmp/other/opencode.json`)).toBeUndefined()
+})
+
+test("matches darwin /private path aliases", () => {
+ if (process.platform !== "darwin") return
+ const privateRoot = "/private/tmp/openwork-hotreload"
+ expect(HotReload.classify(privateRoot, "/tmp/openwork-hotreload/.opencode/commands/fix.md")).toBe(
+ ".opencode/commands/fix.md",
+ )
+})
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 0e99c5648bf6..19f9821c421b 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -1,61 +1,52 @@
import { describe, expect, mock, test } from "bun:test"
-import type { Project as ProjectNS } from "../../src/project/project"
+import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log"
-import { Storage } from "../../src/storage/storage"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
+import { GlobalBus } from "../../src/bus/global"
Log.init({ print: false })
-const bunModule = await import("bun")
+const gitModule = await import("../../src/util/git")
+const originalGit = gitModule.git
+
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
-function render(parts: TemplateStringsArray, vals: unknown[]) {
- return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
-}
-
-function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
- const result = {
- exitCode: output.exitCode,
- stdout: Buffer.from(output.stdout),
- stderr: Buffer.from(output.stderr),
- text: async () => output.stdout,
- }
- const shell = {
- quiet: () => shell,
- nothrow: () => shell,
- cwd: () => shell,
- env: () => shell,
- text: async () => output.stdout,
- then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
- Promise.resolve(result).then(onfulfilled, onrejected),
- catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
- finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
- }
- return shell
-}
-
-mock.module("bun", () => ({
- ...bunModule,
- $: (parts: TemplateStringsArray, ...vals: unknown[]) => {
- const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
+mock.module("../../src/util/git", () => ({
+ git: (args: string[], opts: { cwd: string; env?: Record }) => {
+ const cmd = ["git", ...args].join(" ")
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("--all")
) {
- return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+ return Promise.resolve({
+ exitCode: 128,
+ text: () => Promise.resolve(""),
+ stdout: Buffer.from(""),
+ stderr: Buffer.from("fatal"),
+ })
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
- return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+ return Promise.resolve({
+ exitCode: 128,
+ text: () => Promise.resolve(""),
+ stdout: Buffer.from(""),
+ stderr: Buffer.from("fatal"),
+ })
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
- return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
+ return Promise.resolve({
+ exitCode: 128,
+ text: () => Promise.resolve(""),
+ stdout: Buffer.from(""),
+ stderr: Buffer.from("fatal"),
+ })
}
- return (bunModule.$ as any)(parts, ...vals)
+ return originalGit(args, opts)
},
}))
@@ -161,38 +152,51 @@ describe("Project.fromDirectory with worktrees", () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
- const worktreePath = path.join(tmp.path, "..", "worktree-test")
- await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet()
+ const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
+ try {
+ await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
- const { project, sandbox } = await p.fromDirectory(worktreePath)
+ const { project, sandbox } = await p.fromDirectory(worktreePath)
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(worktreePath)
- expect(project.sandboxes).toContain(worktreePath)
- expect(project.sandboxes).not.toContain(tmp.path)
-
- await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
+ expect(project.worktree).toBe(tmp.path)
+ expect(sandbox).toBe(worktreePath)
+ expect(project.sandboxes).toContain(worktreePath)
+ expect(project.sandboxes).not.toContain(tmp.path)
+ } finally {
+ await $`git worktree remove ${worktreePath}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ }
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
- const worktree1 = path.join(tmp.path, "..", "worktree-1")
- const worktree2 = path.join(tmp.path, "..", "worktree-2")
- await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet()
- await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet()
-
- await p.fromDirectory(worktree1)
- const { project } = await p.fromDirectory(worktree2)
+ const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
+ const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2")
+ try {
+ await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
+ await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
- expect(project.worktree).toBe(tmp.path)
- expect(project.sandboxes).toContain(worktree1)
- expect(project.sandboxes).toContain(worktree2)
- expect(project.sandboxes).not.toContain(tmp.path)
+ await p.fromDirectory(worktree1)
+ const { project } = await p.fromDirectory(worktree2)
- await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet()
- await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet()
+ expect(project.worktree).toBe(tmp.path)
+ expect(project.sandboxes).toContain(worktree1)
+ expect(project.sandboxes).toContain(worktree2)
+ expect(project.sandboxes).not.toContain(tmp.path)
+ } finally {
+ await $`git worktree remove ${worktree1}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ await $`git worktree remove ${worktree2}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ }
})
})
@@ -207,11 +211,12 @@ describe("Project.discover", () => {
await p.discover(project)
- const updated = await Storage.read(["project", project.id])
- expect(updated.icon).toBeDefined()
- expect(updated.icon?.url).toStartWith("data:")
- expect(updated.icon?.url).toContain("base64")
- expect(updated.icon?.color).toBeUndefined()
+ const updated = Project.get(project.id)
+ expect(updated).toBeDefined()
+ expect(updated!.icon).toBeDefined()
+ expect(updated!.icon?.url).toStartWith("data:")
+ expect(updated!.icon?.url).toContain("base64")
+ expect(updated!.icon?.color).toBeUndefined()
})
test("should not discover non-image files", async () => {
@@ -223,7 +228,120 @@ describe("Project.discover", () => {
await p.discover(project)
- const updated = await Storage.read(["project", project.id])
- expect(updated.icon).toBeUndefined()
+ const updated = Project.get(project.id)
+ expect(updated).toBeDefined()
+ expect(updated!.icon).toBeUndefined()
+ })
+})
+
+describe("Project.update", () => {
+ test("should update name", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ name: "New Project Name",
+ })
+
+ expect(updated.name).toBe("New Project Name")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.name).toBe("New Project Name")
+ })
+
+ test("should update icon url", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { url: "https://example.com/icon.png" },
+ })
+
+ expect(updated.icon?.url).toBe("https://example.com/icon.png")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
+ })
+
+ test("should update icon color", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { color: "#ff0000" },
+ })
+
+ expect(updated.icon?.color).toBe("#ff0000")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.color).toBe("#ff0000")
+ })
+
+ test("should update commands", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ commands: { start: "npm run dev" },
+ })
+
+ expect(updated.commands?.start).toBe("npm run dev")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.commands?.start).toBe("npm run dev")
+ })
+
+ test("should throw error when project not found", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await expect(
+ Project.update({
+ projectID: "nonexistent-project-id",
+ name: "Should Fail",
+ }),
+ ).rejects.toThrow("Project not found: nonexistent-project-id")
+ })
+
+ test("should emit GlobalBus event on update", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ let eventFired = false
+ let eventPayload: any = null
+
+ GlobalBus.on("event", (data) => {
+ eventFired = true
+ eventPayload = data
+ })
+
+ await Project.update({
+ projectID: project.id,
+ name: "Updated Name",
+ })
+
+ expect(eventFired).toBe(true)
+ expect(eventPayload.payload.type).toBe("project.updated")
+ expect(eventPayload.payload.properties.name).toBe("Updated Name")
+ })
+
+ test("should update multiple fields at once", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ name: "Multi Update",
+ icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
+ commands: { start: "make start" },
+ })
+
+ expect(updated.name).toBe("Multi Update")
+ expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
+ expect(updated.icon?.color).toBe("#00ff00")
+ expect(updated.commands?.start).toBe("make start")
})
})
diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts
new file mode 100644
index 000000000000..32d38fe84dec
--- /dev/null
+++ b/packages/opencode/test/project/worktree-remove.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+import path from "path"
+import { Instance } from "../../src/project/instance"
+import { Worktree } from "../../src/worktree"
+import { tmpdir } from "../fixture/fixture"
+
+describe("Worktree.remove", () => {
+ test("continues when git remove exits non-zero after detaching", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const root = tmp.path
+ const name = `remove-regression-${Date.now().toString(36)}`
+ const branch = `opencode/${name}`
+ const dir = path.join(root, "..", name)
+
+ await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
+ await $`git reset --hard`.cwd(dir).quiet()
+
+ const real = (await $`which git`.quiet().text()).trim()
+ expect(real).toBeTruthy()
+
+ const bin = path.join(root, "bin")
+ const shim = path.join(bin, "git")
+ await fs.mkdir(bin, { recursive: true })
+ await Bun.write(
+ shim,
+ [
+ "#!/bin/bash",
+ `REAL_GIT=${JSON.stringify(real)}`,
+ 'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
+ ' "$REAL_GIT" "$@" >/dev/null 2>&1',
+ ' echo "fatal: failed to remove worktree: Directory not empty" >&2',
+ " exit 1",
+ "fi",
+ 'exec "$REAL_GIT" "$@"',
+ ].join("\n"),
+ )
+ await fs.chmod(shim, 0o755)
+
+ const prev = process.env.PATH ?? ""
+ process.env.PATH = `${bin}${path.delimiter}${prev}`
+
+ const ok = await (async () => {
+ try {
+ return await Instance.provide({
+ directory: root,
+ fn: () => Worktree.remove({ directory: dir }),
+ })
+ } finally {
+ process.env.PATH = prev
+ }
+ })()
+
+ expect(ok).toBe(true)
+ expect(await Bun.file(dir).exists()).toBe(false)
+
+ const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
+ expect(list).not.toContain(`worktree ${dir}`)
+
+ const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
+ expect(ref.exitCode).not.toBe(0)
+ })
+})
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 02bb5278fc7d..3494cb56fdd0 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -175,6 +175,204 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
})
})
+describe("ProviderTransform.options - gateway", () => {
+ const sessionID = "test-session-123"
+
+ const createModel = (id: string) =>
+ ({
+ id,
+ providerID: "vercel",
+ api: {
+ id,
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ name: id,
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, video: false, pdf: true },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ cost: {
+ input: 0.001,
+ output: 0.002,
+ cache: { read: 0.0001, write: 0.0002 },
+ },
+ limit: {
+ context: 200_000,
+ output: 8192,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ release_date: "2024-01-01",
+ }) as any
+
+ test("puts gateway defaults under gateway key", () => {
+ const model = createModel("anthropic/claude-sonnet-4")
+ const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
+ expect(result).toEqual({
+ gateway: {
+ caching: "auto",
+ },
+ })
+ })
+})
+
+describe("ProviderTransform.providerOptions", () => {
+ const createModel = (overrides: Partial = {}) =>
+ ({
+ id: "test/test-model",
+ providerID: "test",
+ api: {
+ id: "test-model",
+ url: "https://api.test.com",
+ npm: "@ai-sdk/openai",
+ },
+ name: "Test Model",
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, video: false, pdf: false },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ cost: {
+ input: 0.001,
+ output: 0.002,
+ cache: { read: 0.0001, write: 0.0002 },
+ },
+ limit: {
+ context: 200_000,
+ output: 64_000,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ release_date: "2024-01-01",
+ ...overrides,
+ }) as any
+
+ test("uses sdk key for non-gateway models", () => {
+ const model = createModel({
+ providerID: "my-bedrock",
+ api: {
+ id: "anthropic.claude-sonnet-4",
+ url: "https://bedrock.aws",
+ npm: "@ai-sdk/amazon-bedrock",
+ },
+ })
+
+ expect(ProviderTransform.providerOptions(model, { cachePoint: { type: "default" } })).toEqual({
+ bedrock: { cachePoint: { type: "default" } },
+ })
+ })
+
+ test("uses gateway model provider slug for gateway models", () => {
+ const model = createModel({
+ providerID: "vercel",
+ api: {
+ id: "anthropic/claude-sonnet-4",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+
+ expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 12_000 } })).toEqual({
+ anthropic: { thinking: { type: "enabled", budgetTokens: 12_000 } },
+ })
+ })
+
+ test("falls back to gateway key when gateway api id is unscoped", () => {
+ const model = createModel({
+ id: "anthropic/claude-sonnet-4",
+ providerID: "vercel",
+ api: {
+ id: "claude-sonnet-4",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+
+ expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 12_000 } })).toEqual({
+ gateway: { thinking: { type: "enabled", budgetTokens: 12_000 } },
+ })
+ })
+
+ test("splits gateway routing options from provider-specific options", () => {
+ const model = createModel({
+ providerID: "vercel",
+ api: {
+ id: "anthropic/claude-sonnet-4",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+
+ expect(
+ ProviderTransform.providerOptions(model, {
+ gateway: { order: ["vertex", "anthropic"] },
+ thinking: { type: "enabled", budgetTokens: 12_000 },
+ }),
+ ).toEqual({
+ gateway: { order: ["vertex", "anthropic"] },
+ anthropic: { thinking: { type: "enabled", budgetTokens: 12_000 } },
+ } as any)
+ })
+
+ test("falls back to gateway key when model id has no provider slug", () => {
+ const model = createModel({
+ id: "claude-sonnet-4",
+ providerID: "vercel",
+ api: {
+ id: "claude-sonnet-4",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+
+ expect(ProviderTransform.providerOptions(model, { reasoningEffort: "high" })).toEqual({
+ gateway: { reasoningEffort: "high" },
+ })
+ })
+
+ test("maps amazon slug to bedrock for provider options", () => {
+ const model = createModel({
+ providerID: "vercel",
+ api: {
+ id: "amazon/nova-2-lite",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+
+ expect(ProviderTransform.providerOptions(model, { reasoningConfig: { type: "enabled" } })).toEqual({
+ bedrock: { reasoningConfig: { type: "enabled" } },
+ })
+ })
+
+ test("uses groq slug for groq models", () => {
+ const model = createModel({
+ providerID: "vercel",
+ api: {
+ id: "groq/llama-3.3-70b-versatile",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+
+ expect(ProviderTransform.providerOptions(model, { reasoningFormat: "parsed" })).toEqual({
+ groq: { reasoningFormat: "parsed" },
+ })
+ })
+})
+
describe("ProviderTransform.schema - gemini array items", () => {
test("adds missing items for array properties", () => {
const geminiModel = {
@@ -1232,6 +1430,105 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
})
})
+describe("ProviderTransform.message - cache control on gateway", () => {
+ const createModel = (overrides: Partial = {}) =>
+ ({
+ id: "anthropic/claude-sonnet-4",
+ providerID: "vercel",
+ api: {
+ id: "anthropic/claude-sonnet-4",
+ url: "https://ai-gateway.vercel.sh/v3/ai",
+ npm: "@ai-sdk/gateway",
+ },
+ name: "Claude Sonnet 4",
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, video: false, pdf: true },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
+ limit: { context: 200_000, output: 8192 },
+ status: "active",
+ options: {},
+ headers: {},
+ ...overrides,
+ }) as any
+
+ test("gateway does not set cache control for anthropic models", () => {
+ const model = createModel()
+ const msgs = [
+ {
+ role: "system",
+ content: [{ type: "text", text: "You are a helpful assistant" }],
+ },
+ {
+ role: "user",
+ content: "Hello",
+ },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, model, {}) as any[]
+
+ expect(result[0].content[0].providerOptions).toBeUndefined()
+ expect(result[0].providerOptions).toBeUndefined()
+ })
+
+ test("non-gateway anthropic keeps existing cache control behavior", () => {
+ const model = createModel({
+ providerID: "anthropic",
+ api: {
+ id: "claude-sonnet-4",
+ url: "https://api.anthropic.com",
+ npm: "@ai-sdk/anthropic",
+ },
+ })
+ const msgs = [
+ {
+ role: "system",
+ content: "You are a helpful assistant",
+ },
+ {
+ role: "user",
+ content: "Hello",
+ },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, model, {}) as any[]
+
+ expect(result[0].providerOptions).toEqual({
+ anthropic: {
+ cacheControl: {
+ type: "ephemeral",
+ },
+ },
+ openrouter: {
+ cacheControl: {
+ type: "ephemeral",
+ },
+ },
+ bedrock: {
+ cachePoint: {
+ type: "default",
+ },
+ },
+ openaiCompatible: {
+ cache_control: {
+ type: "ephemeral",
+ },
+ },
+ copilot: {
+ copilot_cache_control: {
+ type: "ephemeral",
+ },
+ },
+ })
+ })
+})
+
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial = {}): any => ({
id: "test/test-model",
@@ -1408,6 +1705,32 @@ describe("ProviderTransform.variants", () => {
})
describe("@ai-sdk/gateway", () => {
+ test("anthropic models return anthropic thinking options", () => {
+ const model = createMockModel({
+ id: "anthropic/claude-sonnet-4",
+ providerID: "gateway",
+ api: {
+ id: "anthropic/claude-sonnet-4",
+ url: "https://gateway.ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["high", "max"])
+ expect(result.high).toEqual({
+ thinking: {
+ type: "enabled",
+ budgetTokens: 16000,
+ },
+ })
+ expect(result.max).toEqual({
+ thinking: {
+ type: "enabled",
+ budgetTokens: 31999,
+ },
+ })
+ })
+
test("returns OPENAI_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "gateway/gateway-model",
diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts
new file mode 100644
index 000000000000..b80d373458f4
--- /dev/null
+++ b/packages/opencode/test/pty/pty-output-isolation.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from "bun:test"
+import { Instance } from "../../src/project/instance"
+import { Pty } from "../../src/pty"
+import { tmpdir } from "../fixture/fixture"
+
+describe("pty", () => {
+ test("does not leak output when websocket objects are reused", async () => {
+ await using dir = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: dir.path,
+ fn: async () => {
+ const a = await Pty.create({ command: "cat", title: "a" })
+ const b = await Pty.create({ command: "cat", title: "b" })
+ try {
+ const outA: string[] = []
+ const outB: string[] = []
+
+ const ws = {
+ readyState: 1,
+ send: (data: unknown) => {
+ outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
+ },
+ close: () => {
+ // no-op (simulate abrupt drop)
+ },
+ }
+
+ // Connect "a" first with ws.
+ Pty.connect(a.id, ws as any)
+
+ // Now "reuse" the same ws object for another connection.
+ ws.send = (data: unknown) => {
+ outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
+ }
+ Pty.connect(b.id, ws as any)
+
+ // Clear connect metadata writes.
+ outA.length = 0
+ outB.length = 0
+
+ // Output from a must never show up in b.
+ Pty.write(a.id, "AAA\n")
+ await Bun.sleep(100)
+
+ expect(outB.join("")).not.toContain("AAA")
+ } finally {
+ await Pty.remove(a.id)
+ await Pty.remove(b.id)
+ }
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts
index 623c16a8114f..675a89011f96 100644
--- a/packages/opencode/test/server/session-list.test.ts
+++ b/packages/opencode/test/server/session-list.test.ts
@@ -1,20 +1,17 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
-import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
-describe("session.list", () => {
+describe("Session.list", () => {
test("filters by directory", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
- const app = Server.App()
-
const first = await Session.create({})
const otherDir = path.join(projectRoot, "..", "__session_list_other")
@@ -23,17 +20,71 @@ describe("session.list", () => {
fn: async () => Session.create({}),
})
- const response = await app.request(`/session?directory=${encodeURIComponent(projectRoot)}`)
- expect(response.status).toBe(200)
-
- const body = (await response.json()) as unknown[]
- const ids = body
- .map((s) => (typeof s === "object" && s && "id" in s ? (s as { id: string }).id : undefined))
- .filter((x): x is string => typeof x === "string")
+ const sessions = [...Session.list({ directory: projectRoot })]
+ const ids = sessions.map((s) => s.id)
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
},
})
})
+
+ test("filters root sessions", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const root = await Session.create({ title: "root-session" })
+ const child = await Session.create({ title: "child-session", parentID: root.id })
+
+ const sessions = [...Session.list({ roots: true })]
+ const ids = sessions.map((s) => s.id)
+
+ expect(ids).toContain(root.id)
+ expect(ids).not.toContain(child.id)
+ },
+ })
+ })
+
+ test("filters by start time", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const session = await Session.create({ title: "new-session" })
+ const futureStart = Date.now() + 86400000
+
+ const sessions = [...Session.list({ start: futureStart })]
+ expect(sessions.length).toBe(0)
+ },
+ })
+ })
+
+ test("filters by search term", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ await Session.create({ title: "unique-search-term-abc" })
+ await Session.create({ title: "other-session-xyz" })
+
+ const sessions = [...Session.list({ search: "unique-search" })]
+ const titles = sessions.map((s) => s.title)
+
+ expect(titles).toContain("unique-search-term-abc")
+ expect(titles).not.toContain("other-session-xyz")
+ },
+ })
+ })
+
+ test("respects limit parameter", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ await Session.create({ title: "session-1" })
+ await Session.create({ title: "session-2" })
+ await Session.create({ title: "session-3" })
+
+ const sessions = [...Session.list({ limit: 2 })]
+ expect(sessions.length).toBe(2)
+ },
+ })
+ })
})
diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts
new file mode 100644
index 000000000000..b70c9e1ebe02
--- /dev/null
+++ b/packages/opencode/test/storage/json-migration.test.ts
@@ -0,0 +1,846 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test"
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+import { migrate } from "drizzle-orm/bun-sqlite/migrator"
+import path from "path"
+import fs from "fs/promises"
+import { readFileSync, readdirSync } from "fs"
+import { JsonMigration } from "../../src/storage/json-migration"
+import { Global } from "../../src/global"
+import { ProjectTable } from "../../src/project/project.sql"
+import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
+import { SessionShareTable } from "../../src/share/share.sql"
+
+// Test fixtures
+const fixtures = {
+ project: {
+ id: "proj_test123abc",
+ name: "Test Project",
+ worktree: "/test/path",
+ vcs: "git" as const,
+ sandboxes: [],
+ },
+ session: {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/path",
+ title: "Test Session",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ },
+ message: {
+ id: "msg_test789ghi",
+ sessionID: "ses_test456def",
+ role: "user" as const,
+ agent: "default",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ time: { created: 1700000000000 },
+ },
+ part: {
+ id: "prt_testabc123",
+ messageID: "msg_test789ghi",
+ sessionID: "ses_test456def",
+ type: "text" as const,
+ text: "Hello, world!",
+ },
+}
+
+// Helper to create test storage directory structure
+async function setupStorageDir() {
+ const storageDir = path.join(Global.Path.data, "storage")
+ await fs.rm(storageDir, { recursive: true, force: true })
+ await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session_share"), { recursive: true })
+ // Create legacy marker to indicate JSON storage exists
+ await Bun.write(path.join(storageDir, "migration"), "1")
+ return storageDir
+}
+
+async function writeProject(storageDir: string, project: Record) {
+ await Bun.write(path.join(storageDir, "project", `${project.id}.json`), JSON.stringify(project))
+}
+
+async function writeSession(storageDir: string, projectID: string, session: Record) {
+ await Bun.write(path.join(storageDir, "session", projectID, `${session.id}.json`), JSON.stringify(session))
+}
+
+// Helper to create in-memory test database with schema
+function createTestDb() {
+ const sqlite = new Database(":memory:")
+ sqlite.exec("PRAGMA foreign_keys = ON")
+
+ // Apply schema migrations using drizzle migrate
+ const dir = path.join(import.meta.dirname, "../../migration")
+ const entries = readdirSync(dir, { withFileTypes: true })
+ const migrations = entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => ({
+ sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
+ timestamp: Number(entry.name.split("_")[0]),
+ }))
+ .sort((a, b) => a.timestamp - b.timestamp)
+ migrate(drizzle({ client: sqlite }), migrations)
+
+ return sqlite
+}
+
+describe("JSON to SQLite migration", () => {
+ let storageDir: string
+ let sqlite: Database
+
+ beforeEach(async () => {
+ storageDir = await setupStorageDir()
+ sqlite = createTestDb()
+ })
+
+ afterEach(async () => {
+ sqlite.close()
+ await fs.rm(storageDir, { recursive: true, force: true })
+ })
+
+ test("migrates project", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Test Project",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: ["/test/sandbox"],
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_test123abc")
+ expect(projects[0].worktree).toBe("/test/path")
+ expect(projects[0].name).toBe("Test Project")
+ expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
+ })
+
+ test("uses filename for project id when JSON has different value", async () => {
+ await Bun.write(
+ path.join(storageDir, "project", "proj_filename.json"),
+ JSON.stringify({
+ id: "proj_different_in_json", // Stale! Should be ignored
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Test Project",
+ sandboxes: [],
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_filename") // Uses filename, not JSON id
+ })
+
+ test("migrates project with commands", async () => {
+ await writeProject(storageDir, {
+ id: "proj_with_commands",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Project With Commands",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: ["/test/sandbox"],
+ commands: { start: "npm run dev" },
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_with_commands")
+ expect(projects[0].commands).toEqual({ start: "npm run dev" })
+ })
+
+ test("migrates project without commands field", async () => {
+ await writeProject(storageDir, {
+ id: "proj_no_commands",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Project Without Commands",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: [],
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_no_commands")
+ expect(projects[0].commands).toBeNull()
+ })
+
+ test("migrates session with individual columns", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await writeSession(storageDir, "proj_test123abc", {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/dir",
+ title: "Test Session Title",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ summary: { additions: 10, deletions: 5, files: 3 },
+ share: { url: "https://example.com/share" },
+ })
+
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const sessions = db.select().from(SessionTable).all()
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].id).toBe("ses_test456def")
+ expect(sessions[0].project_id).toBe("proj_test123abc")
+ expect(sessions[0].slug).toBe("test-session")
+ expect(sessions[0].title).toBe("Test Session Title")
+ expect(sessions[0].summary_additions).toBe(10)
+ expect(sessions[0].summary_deletions).toBe(5)
+ expect(sessions[0].share_url).toBe("https://example.com/share")
+ })
+
+ test("migrates messages and parts", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
+ JSON.stringify({ ...fixtures.message }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
+ JSON.stringify({ ...fixtures.part }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_test789ghi")
+
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_testabc123")
+ })
+
+ test("migrates legacy parts without ids in body", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
+ JSON.stringify({
+ role: "user",
+ agent: "default",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ time: { created: 1700000000000 },
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
+ JSON.stringify({
+ type: "text",
+ text: "Hello, world!",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_test789ghi")
+ expect(messages[0].session_id).toBe("ses_test456def")
+ expect(messages[0].data).not.toHaveProperty("id")
+ expect(messages[0].data).not.toHaveProperty("sessionID")
+
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_testabc123")
+ expect(parts[0].message_id).toBe("msg_test789ghi")
+ expect(parts[0].session_id).toBe("ses_test456def")
+ expect(parts[0].data).not.toHaveProperty("id")
+ expect(parts[0].data).not.toHaveProperty("messageID")
+ expect(parts[0].data).not.toHaveProperty("sessionID")
+ })
+
+ test("uses filename for message id when JSON has different value", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_from_filename.json"),
+ JSON.stringify({
+ id: "msg_different_in_json", // Stale! Should be ignored
+ sessionID: "ses_test456def",
+ role: "user",
+ agent: "default",
+ time: { created: 1700000000000 },
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id
+ expect(messages[0].session_id).toBe("ses_test456def")
+ })
+
+ test("uses paths for part id and messageID when JSON has different values", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_realmsgid.json"),
+ JSON.stringify({
+ role: "user",
+ agent: "default",
+ time: { created: 1700000000000 },
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_realmsgid", "prt_from_filename.json"),
+ JSON.stringify({
+ id: "prt_different_in_json", // Stale! Should be ignored
+ messageID: "msg_different_in_json", // Stale! Should be ignored
+ sessionID: "ses_test456def",
+ type: "text",
+ text: "Hello",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id
+ expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID
+ })
+
+ test("skips orphaned sessions (no parent project)", async () => {
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
+ JSON.stringify({
+ id: "ses_orphan",
+ projectID: "proj_nonexistent",
+ slug: "orphan",
+ directory: "/",
+ title: "Orphan",
+ version: "1.0.0",
+ time: { created: Date.now(), updated: Date.now() },
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.sessions).toBe(0)
+ })
+
+ test("uses directory path for projectID when JSON has stale value", async () => {
+ // Simulates the scenario where earlier migration moved sessions to new
+ // git-based project directories but didn't update the projectID field
+ const gitBasedProjectID = "abc123gitcommit"
+ await writeProject(storageDir, {
+ id: gitBasedProjectID,
+ worktree: "/test/path",
+ vcs: "git",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ // Session is in the git-based directory but JSON still has old projectID
+ await writeSession(storageDir, gitBasedProjectID, {
+ id: "ses_migrated",
+ projectID: "old-project-name", // Stale! Should be ignored
+ slug: "migrated-session",
+ directory: "/test/path",
+ title: "Migrated Session",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.sessions).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const sessions = db.select().from(SessionTable).all()
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].id).toBe("ses_migrated")
+ expect(sessions[0].project_id).toBe(gitBasedProjectID) // Uses directory, not stale JSON
+ })
+
+ test("uses filename for session id when JSON has different value", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_from_filename.json"),
+ JSON.stringify({
+ id: "ses_different_in_json", // Stale! Should be ignored
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/path",
+ title: "Test Session",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.sessions).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const sessions = db.select().from(SessionTable).all()
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id
+ expect(sessions[0].project_id).toBe("proj_test123abc")
+ })
+
+ test("is idempotent (running twice doesn't duplicate)", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await JsonMigration.run(sqlite)
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
+ })
+
+ test("migrates todos", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ // Create todo file (named by sessionID, contains array of todos)
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ {
+ id: "todo_1",
+ content: "First todo",
+ status: "pending",
+ priority: "high",
+ },
+ {
+ id: "todo_2",
+ content: "Second todo",
+ status: "completed",
+ priority: "medium",
+ },
+ ]),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.todos).toBe(2)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("First todo")
+ expect(todos[0].status).toBe("pending")
+ expect(todos[0].priority).toBe("high")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("Second todo")
+ expect(todos[1].position).toBe(1)
+ })
+
+ test("todos are ordered by position", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "Third", status: "pending", priority: "low" },
+ { content: "First", status: "pending", priority: "high" },
+ { content: "Second", status: "in_progress", priority: "medium" },
+ ]),
+ )
+
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+
+ expect(todos.length).toBe(3)
+ expect(todos[0].content).toBe("Third")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("First")
+ expect(todos[1].position).toBe(1)
+ expect(todos[2].content).toBe("Second")
+ expect(todos[2].position).toBe(2)
+ })
+
+ test("migrates permissions", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ // Create permission file (named by projectID, contains array of rules)
+ const permissionData = [
+ { permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
+ { permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
+ { permission: "command.run", pattern: "npm install", action: "deny" as const },
+ ]
+ await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.permissions).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const permissions = db.select().from(PermissionTable).all()
+ expect(permissions.length).toBe(1)
+ expect(permissions[0].project_id).toBe("proj_test123abc")
+ expect(permissions[0].data).toEqual(permissionData)
+ })
+
+ test("migrates session shares", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ // Create session share file (named by sessionID)
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({
+ id: "share_123",
+ secret: "supersecretkey",
+ url: "https://share.example.com/ses_test456def",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.shares).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const shares = db.select().from(SessionShareTable).all()
+ expect(shares.length).toBe(1)
+ expect(shares[0].session_id).toBe("ses_test456def")
+ expect(shares[0].id).toBe("share_123")
+ expect(shares[0].secret).toBe("supersecretkey")
+ expect(shares[0].url).toBe("https://share.example.com/ses_test456def")
+ })
+
+ test("returns empty stats when storage directory does not exist", async () => {
+ await fs.rm(storageDir, { recursive: true, force: true })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(0)
+ expect(stats.sessions).toBe(0)
+ expect(stats.messages).toBe(0)
+ expect(stats.parts).toBe(0)
+ expect(stats.todos).toBe(0)
+ expect(stats.permissions).toBe(0)
+ expect(stats.shares).toBe(0)
+ expect(stats.errors).toEqual([])
+ })
+
+ test("continues when a JSON file is unreadable and records an error", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(1)
+ expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_test123abc")
+ })
+
+ test("skips invalid todo entries while preserving source positions", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "keep-0", status: "pending", priority: "high" },
+ { content: "drop-1", priority: "low" },
+ { content: "keep-2", status: "completed", priority: "medium" },
+ ]),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+ expect(stats.todos).toBe(2)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("keep-0")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("keep-2")
+ expect(todos[1].position).toBe(2)
+ })
+
+ test("skips orphaned todos, permissions, and shares", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([{ content: "valid", status: "pending", priority: "high" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_missing.json"),
+ JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_test123abc.json"),
+ JSON.stringify([{ permission: "file.read" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_missing.json"),
+ JSON.stringify([{ permission: "file.write" }]),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_missing.json"),
+ JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.todos).toBe(1)
+ expect(stats.permissions).toBe(1)
+ expect(stats.shares).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ expect(db.select().from(TodoTable).all().length).toBe(1)
+ expect(db.select().from(PermissionTable).all().length).toBe(1)
+ expect(db.select().from(SessionShareTable).all().length).toBe(1)
+ })
+
+ test("handles mixed corruption and partial validity in one migration run", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/ok",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: [],
+ })
+ await Bun.write(
+ path.join(storageDir, "project", "proj_missing_id.json"),
+ JSON.stringify({ worktree: "/bad", sandboxes: [] }),
+ )
+ await Bun.write(path.join(storageDir, "project", "proj_broken.json"), "{ nope")
+
+ await writeSession(storageDir, "proj_test123abc", {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "ok",
+ directory: "/ok",
+ title: "Ok",
+ version: "1",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ })
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_missing_project.json"),
+ JSON.stringify({
+ id: "ses_missing_project",
+ slug: "bad",
+ directory: "/bad",
+ title: "Bad",
+ version: "1",
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
+ JSON.stringify({
+ id: "ses_orphan",
+ projectID: "proj_missing",
+ slug: "orphan",
+ directory: "/bad",
+ title: "Orphan",
+ version: "1",
+ }),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_ok.json"),
+ JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
+ )
+ await Bun.write(path.join(storageDir, "message", "ses_test456def", "msg_broken.json"), "{ nope")
+ await Bun.write(
+ path.join(storageDir, "message", "ses_missing", "msg_orphan.json"),
+ JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "part", "msg_ok", "part_ok.json"),
+ JSON.stringify({ type: "text", text: "ok" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_missing", "part_missing_message.json"),
+ JSON.stringify({ type: "text", text: "bad" }),
+ )
+ await Bun.write(path.join(storageDir, "part", "msg_ok", "part_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "ok", status: "pending", priority: "high" },
+ { content: "skip", status: "pending" },
+ ]),
+ )
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_missing.json"),
+ JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
+ )
+ await Bun.write(path.join(storageDir, "todo", "ses_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_test123abc.json"),
+ JSON.stringify([{ permission: "file.read" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_missing.json"),
+ JSON.stringify([{ permission: "file.write" }]),
+ )
+ await Bun.write(path.join(storageDir, "permission", "proj_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_missing.json"),
+ JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
+ )
+ await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
+
+ const stats = await JsonMigration.run(sqlite)
+
+ // Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
+ // Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
+ // ses_orphan (now uses dir path, ignores stale projectID)
+ expect(stats.projects).toBe(2)
+ expect(stats.sessions).toBe(3)
+ expect(stats.messages).toBe(1)
+ expect(stats.parts).toBe(1)
+ expect(stats.todos).toBe(1)
+ expect(stats.permissions).toBe(1)
+ expect(stats.shares).toBe(1)
+ expect(stats.errors.length).toBeGreaterThanOrEqual(6)
+
+ const db = drizzle({ client: sqlite })
+ expect(db.select().from(ProjectTable).all().length).toBe(2)
+ expect(db.select().from(SessionTable).all().length).toBe(3)
+ expect(db.select().from(MessageTable).all().length).toBe(1)
+ expect(db.select().from(PartTable).all().length).toBe(1)
+ expect(db.select().from(TodoTable).all().length).toBe(1)
+ expect(db.select().from(PermissionTable).all().length).toBe(1)
+ expect(db.select().from(SessionShareTable).all().length).toBe(1)
+ })
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 7040059f33b0..82618195c9b5 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.1.63",
+ "version": "1.2.5",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 664f2c967315..bd4ba530498d 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -224,4 +224,8 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise
+ /**
+ * Modify tool definitions (description and parameters) sent to LLM
+ */
+ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise
}
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 7a47fbfa665f..6b3494e0c60d 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.1.63",
+ "version": "1.2.5",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index af79c44a17a7..381fe797b179 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -24,6 +24,8 @@ import type {
EventTuiPromptAppend,
EventTuiSessionSelect,
EventTuiToastShow,
+ ExperimentalHotreloadApplyErrors,
+ ExperimentalHotreloadApplyResponses,
ExperimentalResourceListResponses,
FileListResponses,
FilePartInput,
@@ -719,6 +721,82 @@ export class Config2 extends HeyApiClient {
}
}
+export class Hotreload extends HeyApiClient {
+ /**
+ * Apply hot reload
+ *
+ * Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.
+ */
+ public apply(
+ parameters?: {
+ directory?: string
+ file?: string
+ event?: "add" | "change" | "unlink"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "body", key: "file" },
+ { in: "body", key: "event" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ ExperimentalHotreloadApplyResponses,
+ ExperimentalHotreloadApplyErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/hotreload",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
+export class Resource extends HeyApiClient {
+ /**
+ * Get MCP resources
+ *
+ * Get all available MCP resources from connected servers. Optionally filter by name.
+ */
+ public list(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get({
+ url: "/experimental/resource",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Experimental extends HeyApiClient {
+ private _hotreload?: Hotreload
+ get hotreload(): Hotreload {
+ return (this._hotreload ??= new Hotreload({ client: this.client }))
+ }
+
+ private _resource?: Resource
+ get resource(): Resource {
+ return (this._resource ??= new Resource({ client: this.client }))
+ }
+}
+
export class Tool extends HeyApiClient {
/**
* List tool IDs
@@ -898,34 +976,6 @@ export class Worktree extends HeyApiClient {
}
}
-export class Resource extends HeyApiClient {
- /**
- * Get MCP resources
- *
- * Get all available MCP resources from connected servers. Optionally filter by name.
- */
- public list(
- parameters?: {
- directory?: string
- },
- options?: Options,
- ) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
- return (options?.client ?? this.client).get({
- url: "/experimental/resource",
- ...options,
- ...params,
- })
- }
-}
-
-export class Experimental extends HeyApiClient {
- private _resource?: Resource
- get resource(): Resource {
- return (this._resource ??= new Resource({ client: this.client }))
- }
-}
-
export class Session extends HeyApiClient {
/**
* List sessions
@@ -3216,6 +3266,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._config ??= new Config2({ client: this.client }))
}
+ private _experimental?: Experimental
+ get experimental(): Experimental {
+ return (this._experimental ??= new Experimental({ client: this.client }))
+ }
+
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
@@ -3226,11 +3281,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._worktree ??= new Worktree({ client: this.client }))
}
- private _experimental?: Experimental
- get experimental(): Experimental {
- return (this._experimental ??= new Experimental({ client: this.client }))
- }
-
private _session?: Session
get session(): Session {
return (this._session ??= new Session({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index b22b7e9af4e1..edd01f4c6884 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -525,7 +525,17 @@ export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
- delta?: string
+ }
+}
+
+export type EventMessagePartDelta = {
+ type: "message.part.delta"
+ properties: {
+ sessionID: string
+ messageID: string
+ partID: string
+ field: string
+ delta: string
}
}
@@ -695,10 +705,6 @@ export type Todo = {
* Priority level of the task: high, medium, low
*/
priority: string
- /**
- * Unique identifier for the todo item
- */
- id: string
}
export type EventTodoUpdated = {
@@ -935,6 +941,22 @@ export type EventWorktreeFailed = {
}
}
+export type EventOpencodeHotreloadChanged = {
+ type: "opencode.hotreload.changed"
+ properties: {
+ file: string
+ event: "add" | "change" | "unlink"
+ }
+}
+
+export type EventOpencodeHotreloadApplied = {
+ type: "opencode.hotreload.applied"
+ properties: {
+ file: string
+ event: "add" | "change" | "unlink"
+ }
+}
+
export type Event =
| EventInstallationUpdated
| EventInstallationUpdateAvailable
@@ -948,6 +970,7 @@ export type Event =
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
+ | EventMessagePartDelta
| EventMessagePartRemoved
| EventPermissionAsked
| EventPermissionReplied
@@ -978,6 +1001,8 @@ export type Event =
| EventPtyDeleted
| EventWorktreeReady
| EventWorktreeFailed
+ | EventOpencodeHotreloadChanged
+ | EventOpencodeHotreloadApplied
export type GlobalEvent = {
directory: string
@@ -2005,6 +2030,14 @@ export type Provider = {
}
}
+export type ExperimentalHotReloadResult = {
+ ok: boolean
+ enabled: boolean
+ queued?: boolean
+ sessions?: number
+ wait?: number
+}
+
export type ToolIds = Array
export type ToolListItem = {
@@ -2708,6 +2741,37 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
+export type ExperimentalHotreloadApplyData = {
+ body?: {
+ file?: string
+ event?: "add" | "change" | "unlink"
+ }
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/experimental/hotreload"
+}
+
+export type ExperimentalHotreloadApplyErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ExperimentalHotreloadApplyError = ExperimentalHotreloadApplyErrors[keyof ExperimentalHotreloadApplyErrors]
+
+export type ExperimentalHotreloadApplyResponses = {
+ /**
+ * Hot reload scheduled
+ */
+ 200: ExperimentalHotReloadResult
+}
+
+export type ExperimentalHotreloadApplyResponse =
+ ExperimentalHotreloadApplyResponses[keyof ExperimentalHotreloadApplyResponses]
+
export type ToolIdsData = {
body?: never
path?: never
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 70596431bb62..8c52e0cab865 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -906,6 +906,68 @@
]
}
},
+ "/experimental/hotreload": {
+ "post": {
+ "operationId": "experimental.hotreload.apply",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Apply hot reload",
+ "description": "Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.",
+ "responses": {
+ "200": {
+ "description": "Hot reload scheduled",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExperimentalHotReloadResult"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestError"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "type": "string"
+ },
+ "event": {
+ "type": "string",
+ "enum": ["add", "change", "unlink"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.hotreload.apply({\n ...\n})"
+ }
+ ]
+ }
+ },
"/experimental/tool/ids": {
"get": {
"operationId": "tool.ids",
@@ -7338,12 +7400,40 @@
"properties": {
"part": {
"$ref": "#/components/schemas/Part"
+ }
+ },
+ "required": ["part"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.message.part.delta": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "message.part.delta"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "sessionID": {
+ "type": "string"
+ },
+ "messageID": {
+ "type": "string"
+ },
+ "partID": {
+ "type": "string"
+ },
+ "field": {
+ "type": "string"
},
"delta": {
"type": "string"
}
},
- "required": ["part"]
+ "required": ["sessionID", "messageID", "partID", "field", "delta"]
}
},
"required": ["type", "properties"]
@@ -7757,13 +7847,9 @@
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
- },
- "id": {
- "description": "Unique identifier for the todo item",
- "type": "string"
}
},
- "required": ["content", "status", "priority", "id"]
+ "required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
@@ -8396,6 +8482,52 @@
},
"required": ["type", "properties"]
},
+ "Event.opencode.hotreload.changed": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "opencode.hotreload.changed"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "type": "string"
+ },
+ "event": {
+ "type": "string",
+ "enum": ["add", "change", "unlink"]
+ }
+ },
+ "required": ["file", "event"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.opencode.hotreload.applied": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "opencode.hotreload.applied"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "type": "string"
+ },
+ "event": {
+ "type": "string",
+ "enum": ["add", "change", "unlink"]
+ }
+ },
+ "required": ["file", "event"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
"Event": {
"anyOf": [
{
@@ -8434,6 +8566,9 @@
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
+ {
+ "$ref": "#/components/schemas/Event.message.part.delta"
+ },
{
"$ref": "#/components/schemas/Event.message.part.removed"
},
@@ -8523,6 +8658,12 @@
},
{
"$ref": "#/components/schemas/Event.worktree.failed"
+ },
+ {
+ "$ref": "#/components/schemas/Event.opencode.hotreload.changed"
+ },
+ {
+ "$ref": "#/components/schemas/Event.opencode.hotreload.applied"
}
]
},
@@ -10402,6 +10543,27 @@
},
"required": ["id", "name", "source", "env", "options", "models"]
},
+ "ExperimentalHotReloadResult": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "queued": {
+ "type": "boolean"
+ },
+ "sessions": {
+ "type": "number"
+ },
+ "wait": {
+ "type": "number"
+ }
+ },
+ "required": ["ok", "enabled"]
+ },
"ToolIDs": {
"type": "array",
"items": {
diff --git a/packages/slack/package.json b/packages/slack/package.json
index abede0f9d2ba..45eb1d841d65 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.1.63",
+ "version": "1.2.5",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 34720215f1a7..56343983778e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.1.63",
+ "version": "1.2.5",
"type": "module",
"license": "MIT",
"exports": {
diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx
index 76bde1e1566c..c1617b265c4f 100644
--- a/packages/ui/src/components/avatar.tsx
+++ b/packages/ui/src/components/avatar.tsx
@@ -1,5 +1,16 @@
import { type ComponentProps, splitProps, Show } from "solid-js"
+const segmenter =
+ typeof Intl !== "undefined" && "Segmenter" in Intl
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
+ : undefined
+
+function first(value: string) {
+ if (!value) return ""
+ if (!segmenter) return Array.from(value)[0] ?? ""
+ return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? ""
+}
+
export interface AvatarProps extends ComponentProps<"div"> {
fallback: string
src?: string
@@ -36,7 +47,7 @@ export function Avatar(props: AvatarProps) {
...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}),
}}
>
-
+
{(src) => }
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index abe0d7ca9e4e..837cc5337647 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -1,10 +1,27 @@
-import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import {
+ DEFAULT_VIRTUAL_FILE_METRICS,
+ type FileContents,
+ File,
+ FileOptions,
+ LineAnnotation,
+ type SelectedLineRange,
+ type VirtualFileMetrics,
+ VirtualizedFile,
+ Virtualizer,
+} from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
+const VIRTUALIZE_BYTES = 500_000
+const codeMetrics = {
+ ...DEFAULT_VIRTUAL_FILE_METRICS,
+ lineHeight: 24,
+ fileGap: 0,
+} satisfies Partial
+
type SelectionSide = "additions" | "deletions"
export type CodeProps = FileOptions & {
@@ -160,16 +177,28 @@ export function Code(props: CodeProps) {
const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
- const file = createMemo(
- () =>
- new File(
- {
- ...createDefaultOptions("unified"),
- ...others,
- },
- getWorkerPool("unified"),
- ),
- )
+ let instance: File | VirtualizedFile | undefined
+ let virtualizer: Virtualizer | undefined
+ let virtualRoot: Document | HTMLElement | undefined
+
+ const bytes = createMemo(() => {
+ const value = local.file.contents as unknown
+ if (typeof value === "string") return value.length
+ if (Array.isArray(value)) {
+ return value.reduce(
+ (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
+ 0,
+ )
+ }
+ if (value == null) return 0
+ return String(value).length
+ })
+ const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
+
+ const options = createMemo(() => ({
+ ...createDefaultOptions("unified"),
+ ...others,
+ }))
const getRoot = () => {
const host = container.querySelector("diffs-container")
@@ -577,6 +606,14 @@ export function Code(props: CodeProps) {
}
const applySelection = (range: SelectedLineRange | null) => {
+ const current = instance
+ if (!current) return false
+
+ if (virtual()) {
+ current.setSelectedLines(range)
+ return true
+ }
+
const root = getRoot()
if (!root) return false
@@ -584,7 +621,7 @@ export function Code(props: CodeProps) {
if (root.querySelectorAll("[data-line]").length < lines) return false
if (!range) {
- file().setSelectedLines(null)
+ current.setSelectedLines(null)
return true
}
@@ -592,12 +629,12 @@ export function Code(props: CodeProps) {
const end = Math.max(range.start, range.end)
if (start < 1 || end > lines) {
- file().setSelectedLines(null)
+ current.setSelectedLines(null)
return true
}
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
- file().setSelectedLines(null)
+ current.setSelectedLines(null)
return true
}
@@ -608,7 +645,7 @@ export function Code(props: CodeProps) {
return { start: range.start, end: range.end }
})()
- file().setSelectedLines(normalized)
+ current.setSelectedLines(normalized)
return true
}
@@ -619,9 +656,12 @@ export function Code(props: CodeProps) {
const token = renderToken
- const lines = lineCount()
+ const lines = virtual() ? undefined : lineCount()
- const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
+ const isReady = (root: ShadowRoot) =>
+ virtual()
+ ? root.querySelector("[data-line]") != null
+ : root.querySelectorAll("[data-line]").length >= (lines ?? 0)
const notify = () => {
if (token !== renderToken) return
@@ -844,20 +884,41 @@ export function Code(props: CodeProps) {
}
createEffect(() => {
- const current = file()
+ const opts = options()
+ const workerPool = getWorkerPool("unified")
+ const isVirtual = virtual()
- onCleanup(() => {
- current.cleanUp()
- })
- })
-
- createEffect(() => {
observer?.disconnect()
observer = undefined
+ instance?.cleanUp()
+ instance = undefined
+
+ if (!isVirtual && virtualizer) {
+ virtualizer.cleanUp()
+ virtualizer = undefined
+ virtualRoot = undefined
+ }
+
+ const v = (() => {
+ if (!isVirtual) return
+ if (typeof document === "undefined") return
+
+ const root = getScrollParent(wrapper) ?? document
+ if (virtualizer && virtualRoot === root) return virtualizer
+
+ virtualizer?.cleanUp()
+ virtualizer = new Virtualizer()
+ virtualRoot = root
+ virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
+ return virtualizer
+ })()
+
+ instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool)
+
container.innerHTML = ""
const value = text()
- file().render({
+ instance.render({
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
lineAnnotations: local.annotations,
containerWrapper: container,
@@ -910,6 +971,13 @@ export function Code(props: CodeProps) {
onCleanup(() => {
observer?.disconnect()
+ instance?.cleanUp()
+ instance = undefined
+
+ virtualizer?.cleanUp()
+ virtualizer = undefined
+ virtualRoot = undefined
+
clearOverlayScroll()
clearOverlay()
if (findCurrent === host) {
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 0966db75e036..0002232b01c8 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -1,5 +1,5 @@
-import { checksum } from "@opencode-ai/util/encode"
-import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
+import { sampledChecksum } from "@opencode-ai/util/encode"
+import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
@@ -78,14 +78,29 @@ export function Diff(props: DiffProps) {
const mobile = createMediaQuery("(max-width: 640px)")
- const options = createMemo(() => {
- const opts = {
+ const large = createMemo(() => {
+ const before = typeof local.before?.contents === "string" ? local.before.contents : ""
+ const after = typeof local.after?.contents === "string" ? local.after.contents : ""
+ return Math.max(before.length, after.length) > 500_000
+ })
+
+ const largeOptions = {
+ lineDiffType: "none",
+ maxLineDiffLength: 0,
+ tokenizeMaxLineLength: 1,
+ } satisfies Pick, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
+
+ const options = createMemo>(() => {
+ const base = {
...createDefaultOptions(props.diffStyle),
...others,
}
- if (!mobile()) return opts
+
+ const perf = large() ? { ...base, ...largeOptions } : base
+ if (!mobile()) return perf
+
return {
- ...opts,
+ ...perf,
disableLineNumbers: true,
}
})
@@ -528,12 +543,17 @@ export function Diff(props: DiffProps) {
createEffect(() => {
const opts = options()
- const workerPool = getWorkerPool(props.diffStyle)
+ const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
+ const cacheKey = (contents: string) => {
+ if (!large()) return sampledChecksum(contents, contents.length)
+ return sampledChecksum(contents)
+ }
+
instance?.cleanUp()
instance = virtualizer
? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool)
@@ -545,12 +565,12 @@ export function Diff(props: DiffProps) {
oldFile: {
...local.before,
contents: beforeContents,
- cacheKey: checksum(beforeContents),
+ cacheKey: cacheKey(beforeContents),
},
newFile: {
...local.after,
contents: afterContents,
- cacheKey: checksum(afterContents),
+ cacheKey: cacheKey(afterContents),
},
lineAnnotations: annotations,
containerWrapper: container,
diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx
index fa4b6d8c0158..bcb8863c89a0 100644
--- a/packages/ui/src/components/font.tsx
+++ b/packages/ui/src/components/font.tsx
@@ -26,6 +26,8 @@ import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2"
import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2"
import iosevka from "../assets/fonts/iosevka-nerd-font.woff2"
import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2"
+import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2"
+import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2"
type MonoFont = {
family: string
@@ -89,6 +91,11 @@ export const MONO_NERD_FONTS = [
regular: iosevka,
bold: iosevkaBold,
},
+ {
+ family: "GeistMono Nerd Font",
+ regular: geistMono,
+ bold: geistMonoBold,
+ },
] satisfies MonoFont[]
const monoNerdCss = MONO_NERD_FONTS.map(
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index abd5572207ae..aa2347037ea4 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -45,6 +45,7 @@ export interface ListProps extends FilteredListProps {
itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
divider?: boolean
add?: ListAddProps
+ groupHeader?: (group: { category: string; items: T[] }) => JSX.Element
}
export interface ListRef {
@@ -206,7 +207,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
)
}
- function GroupHeader(groupProps: { category: string }): JSX.Element {
+ function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element {
const [stuck, setStuck] = createSignal(false)
const [header, setHeader] = createSignal(undefined)
@@ -228,7 +229,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
return (
- {groupProps.category}
+ {props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
)
}
@@ -323,7 +324,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
return (
-
+
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
index 68ae93bda4ae..27c8f238d519 100644
--- a/packages/ui/src/components/markdown.css
+++ b/packages/ui/src/components/markdown.css
@@ -209,3 +209,8 @@
display: block;
}
}
+
+[data-component="markdown"] a.external-link:hover > code {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 608db818f549..4c3d5628418b 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -49,6 +49,19 @@ type CopyLabels = {
copied: string
}
+const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
+
+function codeUrl(text: string) {
+ const href = text.trim().replace(/[),.;!?]+$/, "")
+ if (!urlPattern.test(href)) return
+ try {
+ const url = new URL(href)
+ return url.toString()
+ } catch {
+ return
+ }
+}
+
function createIcon(path: string, slot: string) {
const icon = document.createElement("div")
icon.setAttribute("data-component", "icon")
@@ -110,9 +123,39 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
wrapper.appendChild(createCopyButton(labels))
}
+ const markCodeLinks = () => {
+ const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
+ for (const code of codeNodes) {
+ const href = codeUrl(code.textContent ?? "")
+ const parentLink =
+ code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
+ ? code.parentElement
+ : null
+
+ if (!href) {
+ if (parentLink) parentLink.replaceWith(code)
+ continue
+ }
+
+ if (parentLink) {
+ parentLink.href = href
+ continue
+ }
+
+ const link = document.createElement("a")
+ link.href = href
+ link.className = "external-link"
+ link.target = "_blank"
+ link.rel = "noopener noreferrer"
+ code.parentNode?.replaceChild(link, code)
+ link.appendChild(code)
+ }
+ }
+
const handleClick = async (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Element)) return
+
const button = target.closest('[data-slot="markdown-copy-button"]')
if (!(button instanceof HTMLButtonElement)) return
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
@@ -132,6 +175,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
for (const block of blocks) {
ensureWrapper(block)
}
+ markCodeLinks()
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index 30bfe3b712a9..46473b75e5f2 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -222,4 +222,30 @@
--line-comment-popover-z: 30;
--line-comment-open-z: 6;
}
+
+ [data-slot="session-review-large-diff"] {
+ padding: 12px;
+ background: var(--background-stronger);
+ }
+
+ [data-slot="session-review-large-diff-title"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-strong);
+ margin-bottom: 4px;
+ }
+
+ [data-slot="session-review-large-diff-meta"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ color: var(--text-weak);
+ word-break: break-word;
+ }
+
+ [data-slot="session-review-large-diff-actions"] {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+ }
}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index fe2475548ea4..5f1e6b1aba5f 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -17,6 +17,26 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
+const MAX_DIFF_LINES = 20_000
+const MAX_DIFF_BYTES = 2_000_000
+
+function linesOver(text: string, max: number) {
+ let lines = 1
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) !== 10) continue
+ lines++
+ if (lines > max) return true
+ }
+ return lines > max
+}
+
+function formatBytes(bytes: number) {
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB`
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`
+}
+
export type SessionReviewDiffStyle = "unified" | "split"
export type SessionReviewComment = {
@@ -326,12 +346,28 @@ export const SessionReview = (props: SessionReviewProps) => {
{(diff) => {
let wrapper: HTMLDivElement | undefined
+ const expanded = createMemo(() => open().includes(diff.file))
+ const [force, setForce] = createSignal(false)
+
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
+ const tooLarge = createMemo(() => {
+ if (!expanded()) return false
+ if (force()) return false
+ if (isImageFile(diff.file)) return false
+
+ const before = beforeText()
+ const after = afterText()
+
+ if (before.length > MAX_DIFF_BYTES || after.length > MAX_DIFF_BYTES) return true
+ if (linesOver(before, MAX_DIFF_LINES) || linesOver(after, MAX_DIFF_LINES)) return true
+ return false
+ })
+
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
@@ -571,94 +607,114 @@ export const SessionReview = (props: SessionReviewProps) => {
scheduleAnchors()
}}
>
-
-
-
-
-
-
-
-
-
- {i18n.t("ui.sessionReview.change.removed")}
-
-
-
-
-
-
- {imageStatus() === "loading" ? "Loading..." : "Image"}
-
-
-
-
- {
- props.onDiffRendered?.()
- scheduleAnchors()
- }}
- enableLineSelection={props.onLineComment != null}
- onLineSelected={handleLineSelected}
- onLineSelectionEnd={handleLineSelectionEnd}
- selectedLines={selectedLines()}
- commentedLines={commentedLines()}
- before={{
- name: diff.file!,
- contents: typeof diff.before === "string" ? diff.before : "",
- }}
- after={{
- name: diff.file!,
- contents: typeof diff.after === "string" ? diff.after : "",
- }}
- />
-
-
-
-
- {(comment) => (
-
-
-
- {(range) => (
-
- setCommenting(null)}
- onSubmit={(comment) => {
- props.onLineComment?.({
- file: diff.file,
- selection: range(),
- comment,
- preview: selectionPreview(diff, range()),
- })
- setCommenting(null)
+
+
+
+
+
+
+
+
+
+
+ {i18n.t("ui.sessionReview.change.removed")}
+
+
+
+
+
+
+ {imageStatus() === "loading"
+ ? i18n.t("ui.sessionReview.image.loading")
+ : i18n.t("ui.sessionReview.image.placeholder")}
+
+
+
+
+
+
+ {i18n.t("ui.sessionReview.largeDiff.title")}
+
+
+ Limit: {MAX_DIFF_LINES.toLocaleString()} lines / {formatBytes(MAX_DIFF_BYTES)}.
+ Current: {formatBytes(Math.max(beforeText().length, afterText().length))}.
+
+
+ setForce(true)}>
+ {i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
+
+
+
+
+
+ {
+ props.onDiffRendered?.()
+ scheduleAnchors()
+ }}
+ enableLineSelection={props.onLineComment != null}
+ onLineSelected={handleLineSelected}
+ onLineSelectionEnd={handleLineSelectionEnd}
+ selectedLines={selectedLines()}
+ commentedLines={commentedLines()}
+ before={{
+ name: diff.file!,
+ contents: typeof diff.before === "string" ? diff.before : "",
}}
+ after={{
+ name: diff.file!,
+ contents: typeof diff.after === "string" ? diff.after : "",
+ }}
+ />
+
+
+
+
+ {(comment) => (
+
- )}
+ )}
+
+
+
+ {(range) => (
+
+ setCommenting(null)}
+ onSubmit={(comment) => {
+ props.onLineComment?.({
+ file: diff.file,
+ selection: range(),
+ comment,
+ preview: selectionPreview(diff, range()),
+ })
+ setCommenting(null)
+ }}
+ />
+
+ )}
+
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 9887ce2fc6a2..414e8a359009 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -560,6 +560,12 @@
overflow-y: auto;
}
+ .retry-error-link,
+ .error-card-link {
+ color: var(--text-strong);
+ text-decoration: underline;
+ }
+
[data-slot="session-turn-collapsible-content-inner"] {
width: 100%;
min-width: 0;
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 9ffa671e6985..c03622105916 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -436,6 +436,11 @@ export function SessionTurn(
if (s.type !== "retry") return
return s
})
+ const isRetryFreeUsageLimitError = createMemo(() => {
+ const r = retry()
+ if (!r) return false
+ return r.message.includes("Free usage exceeded")
+ })
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
@@ -691,10 +696,22 @@ export function SessionTurn(
{(() => {
const r = retry()
if (!r) return ""
- const msg = unwrap(r.message)
+ const msg = isRetryFreeUsageLimitError()
+ ? i18n.t("ui.sessionTurn.error.freeUsageExceeded")
+ : unwrap(r.message)
return msg.length > 60 ? msg.slice(0, 60) + "..." : msg
})()}
+
+
+ {i18n.t("ui.sessionTurn.error.addCredits")}
+
+
· {i18n.t("ui.sessionTurn.retry.retrying")}
{store.retrySeconds > 0
diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx
index a8600aef447d..f4f95baf57d5 100644
--- a/packages/ui/src/components/switch.tsx
+++ b/packages/ui/src/components/switch.tsx
@@ -10,7 +10,7 @@ export interface SwitchProps extends ParentProps>
export function Switch(props: SwitchProps) {
const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"])
return (
-
+
diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css
index de547f9c78e3..4e6504d061f0 100644
--- a/packages/ui/src/components/toast.css
+++ b/packages/ui/src/components/toast.css
@@ -21,6 +21,11 @@
padding: 0;
max-height: 100%;
overflow-y: auto;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
}
@@ -101,6 +106,11 @@
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
[data-slot="toast-title"] {
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts
index 7ee17e2e0102..4a1525d468c0 100644
--- a/packages/ui/src/i18n/ar.ts
+++ b/packages/ui/src/i18n/ar.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "مضاف",
"ui.sessionReview.change.removed": "محذوف",
"ui.sessionReview.change.modified": "معدل",
+ "ui.sessionReview.image.loading": "جار التحميل...",
+ "ui.sessionReview.image.placeholder": "صورة",
+ "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
+ "ui.sessionReview.largeDiff.meta": "الحد: {{lines}} سطر / {{limit}}. الحالي: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
"ui.lineComment.label.prefix": "تعليق على ",
"ui.lineComment.label.suffix": "",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "إعادة المحاولة",
"ui.sessionTurn.retry.inSeconds": "خلال {{seconds}} ثواني",
+ "ui.sessionTurn.error.freeUsageExceeded": "تم تجاوز حد الاستخدام المجاني",
+ "ui.sessionTurn.error.addCredits": "إضافة رصيد",
"ui.sessionTurn.status.delegating": "تفويض العمل",
"ui.sessionTurn.status.planning": "تخطيط الخطوات التالية",
diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts
index 6d7449d8457d..160d07aee217 100644
--- a/packages/ui/src/i18n/br.ts
+++ b/packages/ui/src/i18n/br.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Adicionado",
"ui.sessionReview.change.removed": "Removido",
"ui.sessionReview.change.modified": "Modificado",
+ "ui.sessionReview.image.loading": "Carregando...",
+ "ui.sessionReview.image.placeholder": "Imagem",
+ "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
+ "ui.sessionReview.largeDiff.meta": "Limite: {{lines}} linhas / {{limit}}. Atual: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
"ui.lineComment.label.prefix": "Comentar em ",
"ui.lineComment.label.suffix": "",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "tentando novamente",
"ui.sessionTurn.retry.inSeconds": "em {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Limite de uso gratuito excedido",
+ "ui.sessionTurn.error.addCredits": "Adicionar créditos",
"ui.sessionTurn.status.delegating": "Delegando trabalho",
"ui.sessionTurn.status.planning": "Planejando próximos passos",
diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts
index 24e4c12068ee..9a049c14bc75 100644
--- a/packages/ui/src/i18n/bs.ts
+++ b/packages/ui/src/i18n/bs.ts
@@ -12,6 +12,11 @@ export const dict = {
"ui.sessionReview.change.added": "Dodano",
"ui.sessionReview.change.removed": "Uklonjeno",
"ui.sessionReview.change.modified": "Izmijenjeno",
+ "ui.sessionReview.image.loading": "Učitavanje...",
+ "ui.sessionReview.image.placeholder": "Slika",
+ "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linija / {{limit}}. Trenutno: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
"ui.lineComment.label.prefix": "Komentar na ",
"ui.lineComment.label.suffix": "",
@@ -27,6 +32,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "ponovni pokušaj",
"ui.sessionTurn.retry.inSeconds": "za {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Besplatna upotreba premašena",
+ "ui.sessionTurn.error.addCredits": "Dodaj kredite",
"ui.sessionTurn.status.delegating": "Delegiranje posla",
"ui.sessionTurn.status.planning": "Planiranje sljedećih koraka",
diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts
index 218f3b26a494..de0e854be9e7 100644
--- a/packages/ui/src/i18n/da.ts
+++ b/packages/ui/src/i18n/da.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "Tilføjet",
"ui.sessionReview.change.removed": "Fjernet",
"ui.sessionReview.change.modified": "Ændret",
+ "ui.sessionReview.image.loading": "Indlæser...",
+ "ui.sessionReview.image.placeholder": "Billede",
+ "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
+ "ui.sessionReview.largeDiff.meta": "Grænse: {{lines}} linjer / {{limit}}. Nuværende: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
"ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommenterer på ",
@@ -22,6 +27,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "prøver igen",
"ui.sessionTurn.retry.inSeconds": "om {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Gratis forbrug overskredet",
+ "ui.sessionTurn.error.addCredits": "Tilføj kreditter",
"ui.sessionTurn.status.delegating": "Delegerer arbejde",
"ui.sessionTurn.status.planning": "Planlægger næste trin",
diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts
index 921a12c99675..977065db4c88 100644
--- a/packages/ui/src/i18n/de.ts
+++ b/packages/ui/src/i18n/de.ts
@@ -13,6 +13,11 @@ export const dict = {
"ui.sessionReview.change.added": "Hinzugefügt",
"ui.sessionReview.change.removed": "Entfernt",
"ui.sessionReview.change.modified": "Geändert",
+ "ui.sessionReview.image.loading": "Wird geladen...",
+ "ui.sessionReview.image.placeholder": "Bild",
+ "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} Zeilen / {{limit}}. Aktuell: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
"ui.lineComment.label.prefix": "Kommentar zu ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommentiere ",
@@ -26,6 +31,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "erneuter Versuch",
"ui.sessionTurn.retry.inSeconds": "in {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Kostenloses Nutzungslimit überschritten",
+ "ui.sessionTurn.error.addCredits": "Guthaben aufladen",
"ui.sessionTurn.status.delegating": "Arbeit delegieren",
"ui.sessionTurn.status.planning": "Nächste Schritte planen",
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index 631bc660a65d..59f08e48d399 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Added",
"ui.sessionReview.change.removed": "Removed",
"ui.sessionReview.change.modified": "Modified",
+ "ui.sessionReview.image.loading": "Loading...",
+ "ui.sessionReview.image.placeholder": "Image",
+ "ui.sessionReview.largeDiff.title": "Diff too large to render",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} lines / {{limit}}. Current: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
"ui.lineComment.label.prefix": "Comment on ",
"ui.lineComment.label.suffix": "",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "retrying",
"ui.sessionTurn.retry.inSeconds": "in {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded",
+ "ui.sessionTurn.error.addCredits": "Add credits",
"ui.sessionTurn.status.delegating": "Delegating work",
"ui.sessionTurn.status.planning": "Planning next steps",
diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts
index 4fd921b606b1..6706515ecb38 100644
--- a/packages/ui/src/i18n/es.ts
+++ b/packages/ui/src/i18n/es.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Añadido",
"ui.sessionReview.change.removed": "Eliminado",
"ui.sessionReview.change.modified": "Modificado",
+ "ui.sessionReview.image.loading": "Cargando...",
+ "ui.sessionReview.image.placeholder": "Imagen",
+ "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
+ "ui.sessionReview.largeDiff.meta": "Límite: {{lines}} líneas / {{limit}}. Actual: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
"ui.lineComment.label.prefix": "Comentar en ",
"ui.lineComment.label.suffix": "",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "reintentando",
"ui.sessionTurn.retry.inSeconds": "en {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Límite de uso gratuito excedido",
+ "ui.sessionTurn.error.addCredits": "Añadir créditos",
"ui.sessionTurn.status.delegating": "Delegando trabajo",
"ui.sessionTurn.status.planning": "Planificando siguientes pasos",
diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts
index 537d01bba941..68a687e840f4 100644
--- a/packages/ui/src/i18n/fr.ts
+++ b/packages/ui/src/i18n/fr.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Ajouté",
"ui.sessionReview.change.removed": "Supprimé",
"ui.sessionReview.change.modified": "Modifié",
+ "ui.sessionReview.image.loading": "Chargement...",
+ "ui.sessionReview.image.placeholder": "Image",
+ "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
+ "ui.sessionReview.largeDiff.meta": "Limite : {{lines}} lignes / {{limit}}. Actuel : {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
"ui.lineComment.label.prefix": "Commenter sur ",
"ui.lineComment.label.suffix": "",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "nouvelle tentative",
"ui.sessionTurn.retry.inSeconds": "dans {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Limite d'utilisation gratuite dépassée",
+ "ui.sessionTurn.error.addCredits": "Ajouter des crédits",
"ui.sessionTurn.status.delegating": "Délégation du travail",
"ui.sessionTurn.status.planning": "Planification des prochaines étapes",
diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts
index 6086070bdb2c..6fff28cff435 100644
--- a/packages/ui/src/i18n/ja.ts
+++ b/packages/ui/src/i18n/ja.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "追加",
"ui.sessionReview.change.removed": "削除",
"ui.sessionReview.change.modified": "変更",
+ "ui.sessionReview.image.loading": "読み込み中...",
+ "ui.sessionReview.image.placeholder": "画像",
+ "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
+ "ui.sessionReview.largeDiff.meta": "上限: {{lines}} 行 / {{limit}}。現在: {{current}}。",
+ "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
"ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "へのコメント",
"ui.lineComment.editorLabel.prefix": "",
@@ -22,6 +27,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "再試行中",
"ui.sessionTurn.retry.inSeconds": "{{seconds}}秒後",
+ "ui.sessionTurn.error.freeUsageExceeded": "無料使用制限に達しました",
+ "ui.sessionTurn.error.addCredits": "クレジットを追加",
"ui.sessionTurn.status.delegating": "作業を委任中",
"ui.sessionTurn.status.planning": "次のステップを計画中",
diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts
index fd394dbb7b52..6fac1590d794 100644
--- a/packages/ui/src/i18n/ko.ts
+++ b/packages/ui/src/i18n/ko.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "추가됨",
"ui.sessionReview.change.removed": "삭제됨",
"ui.sessionReview.change.modified": "수정됨",
+ "ui.sessionReview.image.loading": "로딩 중...",
+ "ui.sessionReview.image.placeholder": "이미지",
+ "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
+ "ui.sessionReview.largeDiff.meta": "제한: {{lines}}줄 / {{limit}}. 현재: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
"ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "에 댓글 달기",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "재시도 중",
"ui.sessionTurn.retry.inSeconds": "{{seconds}}초 후",
+ "ui.sessionTurn.error.freeUsageExceeded": "무료 사용량 초과",
+ "ui.sessionTurn.error.addCredits": "크레딧 추가",
"ui.sessionTurn.status.delegating": "작업 위임 중",
"ui.sessionTurn.status.planning": "다음 단계 계획 중",
diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts
index dcb353614d30..160f26a5468a 100644
--- a/packages/ui/src/i18n/no.ts
+++ b/packages/ui/src/i18n/no.ts
@@ -11,6 +11,11 @@ export const dict: Record = {
"ui.sessionReview.change.added": "Lagt til",
"ui.sessionReview.change.removed": "Fjernet",
"ui.sessionReview.change.modified": "Endret",
+ "ui.sessionReview.image.loading": "Laster...",
+ "ui.sessionReview.image.placeholder": "Bilde",
+ "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
+ "ui.sessionReview.largeDiff.meta": "Grense: {{lines}} linjer / {{limit}}. Nåværende: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
"ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "",
@@ -26,6 +31,8 @@ export const dict: Record = {
"ui.sessionTurn.retry.retrying": "Prøver igjen",
"ui.sessionTurn.retry.inSeconds": "om {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Gratis bruk overskredet",
+ "ui.sessionTurn.error.addCredits": "Legg til kreditt",
"ui.sessionTurn.status.delegating": "Delegerer arbeid",
"ui.sessionTurn.status.planning": "Planlegger neste trinn",
diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts
index fb10debbb92d..4882ba034849 100644
--- a/packages/ui/src/i18n/pl.ts
+++ b/packages/ui/src/i18n/pl.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "Dodano",
"ui.sessionReview.change.removed": "Usunięto",
"ui.sessionReview.change.modified": "Zmodyfikowano",
+ "ui.sessionReview.image.loading": "Ładowanie...",
+ "ui.sessionReview.image.placeholder": "Obraz",
+ "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linii / {{limit}}. Obecnie: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
"ui.lineComment.label.prefix": "Komentarz do ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Komentowanie: ",
@@ -22,6 +27,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "ponawianie",
"ui.sessionTurn.retry.inSeconds": "za {{seconds}}s",
+ "ui.sessionTurn.error.freeUsageExceeded": "Przekroczono limit darmowego użytkowania",
+ "ui.sessionTurn.error.addCredits": "Dodaj kredyty",
"ui.sessionTurn.status.delegating": "Delegowanie pracy",
"ui.sessionTurn.status.planning": "Planowanie kolejnych kroków",
diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts
index 417fe0ce8bfe..93a9883d26a7 100644
--- a/packages/ui/src/i18n/ru.ts
+++ b/packages/ui/src/i18n/ru.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "Добавлено",
"ui.sessionReview.change.removed": "Удалено",
"ui.sessionReview.change.modified": "Изменено",
+ "ui.sessionReview.image.loading": "Загрузка...",
+ "ui.sessionReview.image.placeholder": "Изображение",
+ "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
+ "ui.sessionReview.largeDiff.meta": "Лимит: {{lines}} строк / {{limit}}. Текущий: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
"ui.lineComment.label.prefix": "Комментарий к ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Комментирование: ",
@@ -22,6 +27,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "повтор",
"ui.sessionTurn.retry.inSeconds": "через {{seconds}}с",
+ "ui.sessionTurn.error.freeUsageExceeded": "Лимит бесплатного использования превышен",
+ "ui.sessionTurn.error.addCredits": "Добавить кредиты",
"ui.sessionTurn.status.delegating": "Делегирование работы",
"ui.sessionTurn.status.planning": "Планирование следующих шагов",
diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts
index 68bb0d733d99..1a5438a2ae85 100644
--- a/packages/ui/src/i18n/th.ts
+++ b/packages/ui/src/i18n/th.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "เพิ่ม",
"ui.sessionReview.change.removed": "ลบ",
"ui.sessionReview.change.modified": "แก้ไข",
+ "ui.sessionReview.image.loading": "กำลังโหลด...",
+ "ui.sessionReview.image.placeholder": "รูปภาพ",
+ "ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้",
+ "ui.sessionReview.largeDiff.meta": "ขีดจำกัด: {{lines}} บรรทัด / {{limit}}. ปัจจุบัน: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
"ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
"ui.lineComment.label.suffix": "",
@@ -23,6 +28,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "กำลังลองใหม่",
"ui.sessionTurn.retry.inSeconds": "ใน {{seconds}}วิ",
+ "ui.sessionTurn.error.freeUsageExceeded": "เกินขีดจำกัดการใช้งานฟรี",
+ "ui.sessionTurn.error.addCredits": "เพิ่มเครดิต",
"ui.sessionTurn.status.delegating": "มอบหมายงาน",
"ui.sessionTurn.status.planning": "วางแผนขั้นตอนถัดไป",
diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts
index 53beeb1e4f0f..dbebfb3f9f01 100644
--- a/packages/ui/src/i18n/zh.ts
+++ b/packages/ui/src/i18n/zh.ts
@@ -12,6 +12,11 @@ export const dict = {
"ui.sessionReview.change.added": "已添加",
"ui.sessionReview.change.removed": "已移除",
"ui.sessionReview.change.modified": "已修改",
+ "ui.sessionReview.image.loading": "加载中...",
+ "ui.sessionReview.image.placeholder": "图片",
+ "ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
+ "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。当前:{{current}}。",
+ "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.lineComment.label.prefix": "评论 ",
"ui.lineComment.label.suffix": "",
@@ -27,6 +32,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "重试中",
"ui.sessionTurn.retry.inSeconds": "{{seconds}} 秒后",
+ "ui.sessionTurn.error.freeUsageExceeded": "免费使用额度已用完",
+ "ui.sessionTurn.error.addCredits": "添加积分",
"ui.sessionTurn.status.delegating": "正在委派工作",
"ui.sessionTurn.status.planning": "正在规划下一步",
diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts
index 1449b0530ac1..5cec9c399ef2 100644
--- a/packages/ui/src/i18n/zht.ts
+++ b/packages/ui/src/i18n/zht.ts
@@ -12,6 +12,11 @@ export const dict = {
"ui.sessionReview.change.added": "已新增",
"ui.sessionReview.change.removed": "已移除",
"ui.sessionReview.change.modified": "已修改",
+ "ui.sessionReview.image.loading": "載入中...",
+ "ui.sessionReview.image.placeholder": "圖片",
+ "ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
+ "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。目前:{{current}}。",
+ "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.lineComment.label.prefix": "評論 ",
"ui.lineComment.label.suffix": "",
@@ -27,6 +32,8 @@ export const dict = {
"ui.sessionTurn.retry.retrying": "重試中",
"ui.sessionTurn.retry.inSeconds": "{{seconds}} 秒後",
+ "ui.sessionTurn.error.freeUsageExceeded": "免費使用額度已用完",
+ "ui.sessionTurn.error.addCredits": "新增點數",
"ui.sessionTurn.status.delegating": "正在委派工作",
"ui.sessionTurn.status.planning": "正在規劃下一步",
diff --git a/packages/util/package.json b/packages/util/package.json
index 6bc354049b62..7a76bfdec2db 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
- "version": "1.1.63",
+ "version": "1.2.5",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/util/src/encode.ts b/packages/util/src/encode.ts
index 138cf16086df..e4c6e70acb49 100644
--- a/packages/util/src/encode.ts
+++ b/packages/util/src/encode.ts
@@ -28,3 +28,24 @@ export function checksum(content: string): string | undefined {
}
return (hash >>> 0).toString(36)
}
+
+export function sampledChecksum(content: string, limit = 500_000): string | undefined {
+ if (!content) return undefined
+ if (content.length <= limit) return checksum(content)
+
+ const size = 4096
+ const points = [
+ 0,
+ Math.floor(content.length * 0.25),
+ Math.floor(content.length * 0.5),
+ Math.floor(content.length * 0.75),
+ content.length - size,
+ ]
+ const hashes = points
+ .map((point) => {
+ const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2)))
+ return checksum(content.slice(start, start + size)) ?? ""
+ })
+ .join(":")
+ return `${content.length}:${hashes}`
+}
diff --git a/packages/web/package.json b/packages/web/package.json
index 0d04a5adfe67..ff2a7d2686fe 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
- "version": "1.1.63",
+ "version": "1.2.5",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
diff --git a/packages/web/src/content/docs/ar/index.mdx b/packages/web/src/content/docs/ar/index.mdx
index fef8844dc09c..ff2de9c512d8 100644
--- a/packages/web/src/content/docs/ar/index.mdx
+++ b/packages/web/src/content/docs/ar/index.mdx
@@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash
- **باستخدام Paru على Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/da/index.mdx b/packages/web/src/content/docs/da/index.mdx
index b2623b93d542..65cf34f9a951 100644
--- a/packages/web/src/content/docs/da/index.mdx
+++ b/packages/web/src/content/docs/da/index.mdx
@@ -84,7 +84,8 @@ Du kan også installere det med følgende kommandoer:
- **Brug af Paru på Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/de/index.mdx b/packages/web/src/content/docs/de/index.mdx
index e1337254df24..1d7e1f4f96cb 100644
--- a/packages/web/src/content/docs/de/index.mdx
+++ b/packages/web/src/content/docs/de/index.mdx
@@ -84,7 +84,8 @@ Sie können es auch mit den folgenden Befehlen installieren:
- **Verwendung von Paru unter Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/es/index.mdx b/packages/web/src/content/docs/es/index.mdx
index fa8c40c7ff78..b4d3f95b5cf9 100644
--- a/packages/web/src/content/docs/es/index.mdx
+++ b/packages/web/src/content/docs/es/index.mdx
@@ -84,7 +84,8 @@ También puedes instalarlo con los siguientes comandos:
- **Usando Paru en Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx
index 54f36e0cd0e8..0cb947b08f01 100644
--- a/packages/web/src/content/docs/formatters.mdx
+++ b/packages/web/src/content/docs/formatters.mdx
@@ -13,30 +13,31 @@ OpenCode comes with several built-in formatters for popular languages and framew
| 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` |
+| air | .R | `air` command available |
| 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 |
+| cargofmt | .rs | `cargo fmt` command available |
| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file |
+| cljfmt | .clj, .cljs, .cljc, .edn | `cljfmt` command available |
+| dart | .dart | `dart` command available |
+| gleam | .gleam | `gleam` command available |
+| gofmt | .go | `gofmt` command available |
+| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available |
| ktlint | .kt, .kts | `ktlint` command available |
+| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available |
+| nixfmt | .nix | `nixfmt` command available |
+| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file |
+| ormolu | .hs | `ormolu` command available |
+| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) |
+| pint | .php | `laravel/pint` dependency in `composer.json` |
+| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` |
+| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
| ruff | .py, .pyi | `ruff` command available with config |
| rustfmt | .rs | `rustfmt` command available |
-| cargofmt | .rs | `cargo fmt` command available |
-| uv | .py, .pyi | `uv` command available |
-| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
+| shfmt | .sh, .bash | `shfmt` 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 |
-| nixfmt | .nix | `nixfmt` command available |
-| shfmt | .sh, .bash | `shfmt` command available |
-| pint | .php | `laravel/pint` dependency in `composer.json` |
-| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) |
-| ormolu | .hs | `ormolu` command available |
+| uv | .py, .pyi | `uv` command available |
+| zig | .zig, .zon | `zig` command available |
So if your project has `prettier` in your `package.json`, OpenCode will automatically use it.
diff --git a/packages/web/src/content/docs/fr/index.mdx b/packages/web/src/content/docs/fr/index.mdx
index 06d650de7e4d..4f6b2f2a5be6 100644
--- a/packages/web/src/content/docs/fr/index.mdx
+++ b/packages/web/src/content/docs/fr/index.mdx
@@ -84,7 +84,8 @@ Vous pouvez également l'installer avec les commandes suivantes :
- **Via Paru sur Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx
index bb3b8cb5d00c..90e7eafb2f31 100644
--- a/packages/web/src/content/docs/index.mdx
+++ b/packages/web/src/content/docs/index.mdx
@@ -81,10 +81,11 @@ You can also install it with the following commands:
> We recommend using the OpenCode tap for the most up to date releases. The official `brew install opencode` formula is maintained by the Homebrew team and is updated less frequently.
-- **Using Paru on Arch Linux**
+- **Installing on Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/it/index.mdx b/packages/web/src/content/docs/it/index.mdx
index 685bdf0c57a1..1b48d9df6174 100644
--- a/packages/web/src/content/docs/it/index.mdx
+++ b/packages/web/src/content/docs/it/index.mdx
@@ -84,7 +84,8 @@ Puoi anche installarlo con i seguenti comandi:
- **Con Paru su Arch Linux**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/ja/index.mdx b/packages/web/src/content/docs/ja/index.mdx
index 6a6612715fbe..e4e84d7b1cd2 100644
--- a/packages/web/src/content/docs/ja/index.mdx
+++ b/packages/web/src/content/docs/ja/index.mdx
@@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash
- **Arch Linux での Paru の使用**
```bash
- paru -S opencode-bin
+ sudo pacman -S opencode # Arch Linux (Stable)
+ paru -S opencode-bin # Arch Linux (Latest from AUR)
```
#### Windows
diff --git a/packages/web/src/content/docs/ko/acp.mdx b/packages/web/src/content/docs/ko/acp.mdx
index 2fbd58013f8c..a9842f27090a 100644
--- a/packages/web/src/content/docs/ko/acp.mdx
+++ b/packages/web/src/content/docs/ko/acp.mdx
@@ -1,12 +1,12 @@
---
-title: ACP 지원
-description: ACP 호환 편집기에서 opencode를 사용하세요.
+title: ACP Support
+description: Use OpenCode in any ACP-compatible editor.
---
-opencode는 [Agent Client Protocol](https://agentclientprotocol.com) 또는 (ACP)을 지원하며, 호환 편집기 및 IDE에서 직접 사용할 수 있습니다.
+OpenCode는 [Agent Client Protocol](https://agentclientprotocol.com)(ACP)을 지원하므로, ACP 호환 편집기와 IDE에서 OpenCode를 직접 사용할 수 있습니다.
:::tip
-ACP를 지원하는 편집기 및 도구 목록의 경우 [ACP 진행 보고서](https://zed.dev/blog/acp-progress-report#available-now)를 확인하십시오.
+ACP를 지원하는 편집기와 tool 목록은 [ACP progress report](https://zed.dev/blog/acp-progress-report#available-now)에서 확인하세요.
:::
ACP는 코드 편집기와 AI 코딩 에이전트 간의 통신을 표준화하는 개방형 프로토콜입니다.
@@ -15,17 +15,17 @@ ACP는 코드 편집기와 AI 코딩 에이전트 간의 통신을 표준화하
## 구성
-ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려면 편집기를 구성하십시오.
+ACP로 OpenCode를 사용하려면, 편집기에서 `opencode acp` 명령을 실행하도록 config를 설정하세요.
-명령은 opencode를 실행하여 JSON-RPC를 통해 편집기와 통신하는 ACP 호환 하위 프로세스로 시작합니다.
+이 명령은 OpenCode를 ACP 호환 subprocess로 시작하며, stdio 기반 JSON-RPC를 통해 편집기와 통신합니다.
-아래는 ACP를 지원하는 인기있는 편집기의 예입니다.
+아래는 ACP를 지원하는 주요 편집기 예시입니다.
---
-##### Zed
+### Zed
-[Zed](https://zed.dev) 구성 (`~/.config/zed/settings.json`)에 추가 :
+[Zed](https://zed.dev) config(`~/.config/zed/settings.json`)에 다음을 추가하세요.
```json title="~/.config/zed/settings.json"
{
@@ -38,9 +38,9 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려
}
```
-그것을 열려면 **Command Palette **에서 `agent: new thread` 동작을 사용하십시오.
+열려면 **Command Palette**에서 `agent: new thread` action을 사용하세요.
-`keymap.json`를 편집하여 키보드 단축키도 결합할 수 있습니다.
+`keymap.json`을 수정해 키보드 단축키를 바인딩할 수도 있습니다.
```json title="keymap.json"
[
@@ -67,9 +67,9 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려
---
-#### JetBrains IDEs
+### JetBrains IDEs
-[JetBrains IDE]에 추가하십시오 (https://www.jetbrains.com/) [documentation]에 따라 acp.json (https://www.jetbrains.com/help/ai-assistant/acp.html):
+[JetBrains IDE](https://www.jetbrains.com/)에서는 [documentation](https://www.jetbrains.com/help/ai-assistant/acp.html)에 따라 `acp.json`에 다음을 추가하세요.
```json title="acp.json"
{
@@ -82,13 +82,13 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려
}
```
-그것을 열려면 AI Chat Agent selector의 새로운 'opencode' 에이전트를 사용하십시오.
+열려면 AI Chat agent selector에서 새 `OpenCode` agent를 선택하세요.
---
-#### Avante.nvim
+### Avante.nvim
-[Avante.nvim](https://github.com/yetone/avante.nvim) 구성에 추가하십시오:
+[Avante.nvim](https://github.com/yetone/avante.nvim) config에 다음을 추가하세요.
```lua
{
@@ -101,7 +101,7 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려
}
```
-환경 변수를 전달해야 하는 경우:
+환경 변수를 전달해야 한다면 다음과 같이 설정하세요.
```lua {6-8}
{
@@ -119,9 +119,9 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려
---
-#### CodeCompanion.nvim
+### CodeCompanion.nvim
-opencode를 [CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim)에서 ACP 에이전트로 사용하려면 Neovim config에 다음을 추가하십시오.
+[CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim)에서 OpenCode를 ACP agent로 사용하려면 Neovim config에 다음을 추가하세요.
```lua
require("codecompanion").setup({
@@ -136,21 +136,21 @@ require("codecompanion").setup({
})
```
-이 구성은 CodeCompanion을 설정하여 채팅을 위한 ACP 에이전트로 opencode를 사용합니다.
+이 config는 chat에서 OpenCode를 ACP agent로 사용하도록 CodeCompanion을 설정합니다.
-환경 변수 (`OPENCODE_API_KEY`와 같은)를 전달해야하는 경우, CodeCompanion.nvim 문서에서 [Configuring Adapters: Environment variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key)를 참조하십시오.
+환경 변수(`OPENCODE_API_KEY` 등)를 전달해야 한다면 CodeCompanion.nvim documentation의 [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key)를 참고하세요.
-## 지원 기능
+## 지원
-opencode는 터미널과 동일하게 ACP를 통해 작동합니다. 모든 기능은 지원됩니다:
+OpenCode는 ACP를 통해서도 터미널과 동일하게 동작합니다. 다음 기능을 모두 지원합니다.
:::note
`/undo` 및 `/redo`와 같은 일부 내장 슬래시 명령은 현재 지원되지 않습니다.
:::
-- 내장 도구 (파일 작업, terminal 명령 등)
-- 사용자 정의 도구 및 슬래시 명령
-- opencode config에서 설정된 MCP 서버
-- `AGENTS.md`의 프로젝트 별 규칙
-- 사용자 정의 포맷 및 라이터
-- 에이전트 및 권한 시스템
+- 내장 tool(파일 작업, terminal 명령 등)
+- 사용자 정의 tool과 slash command
+- OpenCode config에 설정한 MCP 서버
+- `AGENTS.md`의 프로젝트별 규칙
+- 사용자 정의 formatter와 linter
+- agent 및 권한 시스템
diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx
index 5b37122435fa..34de6250d1d3 100644
--- a/packages/web/src/content/docs/ko/agents.mdx
+++ b/packages/web/src/content/docs/ko/agents.mdx
@@ -1,141 +1,141 @@
---
-title: 에이전트
-description: 전문 에이전트를 구성하고 사용하세요.
+title: Agents
+description: Configure and use specialized agents.
---
-에이전트는 특정 작업과 워크플로우를 전문으로 하는 구성 가능한 AI 보조자입니다. 사용자 정의 프롬프트, 모델, 도구 액세스로 집중된 도구를 만들 수 있습니다.
+agent는 특정 작업과 워크플로에 맞게 설정할 수 있는 전문 AI assistant입니다. custom prompt, model, tool 접근 권한을 조합해 목적에 맞는 agent를 만들 수 있습니다.
:::tip
-코드 변경 없이 제안을 검토할 때는 Plan 에이전트를 사용하세요.
+코드를 수정하지 않고 분석과 제안 검토만 하고 싶다면 plan agent를 사용하세요.
:::
-세션 중에 에이전트를 전환하거나 `@` 멘션으로 호출할 수 있습니다.
+세션 중에 agent를 전환하거나 `@` mention으로 호출할 수 있습니다.
---
## 유형
-OpenCode에는 두 가지 유형의 에이전트가 있습니다: 기본 에이전트(Primary Agent)와 서브 에이전트(Subagent).
+OpenCode의 agent는 primary agent와 subagent, 두 가지 유형으로 나뉩니다.
---
-### 기본 에이전트
+### Primary agents
-기본 에이전트는 사용자가 직접 상호 작용하는 주요 보조자입니다. **Tab** 키 또는 설정된 `switch_agent` 키바인드를 사용하여 순환할 수 있습니다. 이 에이전트는 주요 대화를 처리합니다. 도구 액세스는 권한을 통해 구성됩니다 — 예를 들어, Build는 모든 도구를 사용할 수 있지만 Plan은 제한됩니다.
+primary agent는 사용자가 직접 상호작용하는 메인 assistant입니다. **Tab** 키 또는 설정한 `switch_agent` keybind로 순환 전환할 수 있습니다. primary agent는 메인 대화를 처리하며, tool 접근은 permission으로 제어합니다. 예를 들어 Build는 모든 tool이 활성화되어 있고 Plan은 제한되어 있습니다.
:::tip
-세션 중에 **Tab** 키를 사용하여 기본 에이전트를 전환할 수 있습니다.
+세션 중 **Tab** 키로 primary agent를 빠르게 전환할 수 있습니다.
:::
-OpenCode는 두 가지 내장 기본 에이전트, **Build** 및 **Plan**을 제공합니다. 아래에서 자세히 살펴봅니다.
+OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되어 있습니다. 아래에서 각각 살펴보겠습니다.
---
-### 서브 에이전트
+### Subagents
-서브 에이전트는 기본 에이전트가 특정 작업을 위해 호출할 수 있는 전문 보조자입니다. 또한 메시지에서 **@멘션**을 통해 수동으로 호출할 수도 있습니다.
+subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다.
-OpenCode는 두 가지 내장 서브 에이전트, **General** 및 **Explore**를 제공합니다. 아래에서 자세히 살펴봅니다.
+OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다.
---
## 기본 제공
-OpenCode는 기본 에이전트와 두 개의 내장 서브 에이전트를 제공합니다.
+OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다.
---
-### Build
+### Use build
-_모드_: `primary`
+_Mode_: `primary`
-Build는 모든 도구가 활성화된 **기본** 에이전트입니다. 파일 조작 및 시스템 명령에 대한 전체 액세스가 필요한 개발 작업을 위한 표준 에이전트입니다.
+Build는 모든 tool이 활성화된 **default** primary agent입니다. 파일 작업과 시스템 명령에 대한 전체 접근이 필요한 일반적인 개발 작업에 사용하는 표준 agent입니다.
---
-### Plan
+### Use plan
-_모드_: `primary`
+_Mode_: `primary`
-계획 및 분석을 위해 설계된 제한된 에이전트입니다. 더 많은 제어권을 부여하고 의도하지 않은 변경을 방지하기 위해 권한 시스템을 사용합니다.
-기본적으로 다음은 모두 `ask`로 설정됩니다:
+Plan은 계획과 분석에 특화된 제한형 agent입니다. 더 높은 제어력과 의도치 않은 변경 방지를 위해 permission 시스템을 사용합니다.
+기본값으로 아래 항목은 모두 `ask`로 설정됩니다.
-- `file edits`: 모든 쓰기, 패치 및 편집
+- `file edits`: 모든 write, patch, edit
- `bash`: 모든 bash 명령
-이 에이전트는 코드를 분석하거나 변경을 제안받고 싶지만, 코드베이스에 실제 수정 없이 계획만 만들고 싶을 때 유용합니다.
+코드베이스를 실제로 수정하지 않고 LLM 분석, 변경 제안, 계획 수립만 진행하고 싶을 때 유용합니다.
---
-### General
+### Use general
-_모드_: `subagent`
+_Mode_: `subagent`
-복잡한 질문을 연구하고 다단계 작업을 실행하기 위한 범용 에이전트입니다. 전체 도구 액세스(todo 제외)를 가지므로 필요할 때 파일 변경을 수행할 수 있습니다. 여러 단위의 작업을 병렬로 실행할 때 사용하세요.
+복잡한 질문을 조사하고 다단계 작업을 수행하기 위한 범용 agent입니다. todo를 제외한 모든 tool 접근이 가능하므로 필요하면 파일 수정도 할 수 있습니다. 여러 작업 단위를 병렬로 처리할 때 사용하세요.
---
-### Explore
+### Use explore
-_모드_: `subagent`
+_Mode_: `subagent`
-코드베이스를 탐색하는 빠르고 읽기 전용인 에이전트입니다. 파일을 수정할 수 없습니다. 패턴, 키워드로 코드를 검색하거나 코드베이스에 대한 질문에 답하기 위해 파일을 빠르게 찾아야 할 때 사용하세요.
+코드베이스 탐색에 최적화된 빠른 읽기 전용 agent입니다. 파일을 수정할 수 없습니다. 패턴 기반 파일 탐색, 키워드 검색, 코드베이스 관련 질의 응답을 빠르게 처리할 때 사용하세요.
---
-### Compaction
+### Use compaction
-_모드_: `primary`
+_Mode_: `primary`
-긴 컨텍스트를 작은 요약으로 압축하는 숨겨진 시스템 에이전트입니다. 필요한 경우 자동으로 실행되며 UI에서 선택할 수 없습니다.
+긴 context를 더 짧은 요약으로 압축하는 숨겨진 시스템 agent입니다. 필요할 때 자동으로 실행되며 UI에서 직접 선택할 수 없습니다.
---
-### Title
+### Use title
-_모드_: `primary`
+_Mode_: `primary`
-짧은 세션 제목을 생성하는 숨겨진 시스템 에이전트입니다. 자동으로 실행되며 UI에서 선택할 수 없습니다.
+짧은 세션 제목을 생성하는 숨겨진 시스템 agent입니다. 자동으로 실행되며 UI에서 직접 선택할 수 없습니다.
---
-### Summary
+### Use summary
-_모드_: `primary`
+_Mode_: `primary`
-세션 요약을 만드는 숨겨진 시스템 에이전트입니다. 자동으로 실행되며 UI에서 선택할 수 없습니다.
+세션 요약을 생성하는 숨겨진 시스템 agent입니다. 자동으로 실행되며 UI에서 직접 선택할 수 없습니다.
---
## 사용법
-1. 기본 에이전트의 경우, 세션 중에 **Tab** 키를 사용하여 순환합니다. 구성된 `switch_agent` 키바인드도 사용할 수 있습니다.
+1. primary agent는 세션 중 **Tab** 키로 순환 전환할 수 있습니다. 설정한 `switch_agent` keybind를 사용할 수도 있습니다.
-2. 서브 에이전트는 다음과 같이 호출할 수 있습니다:
- - 설명에 근거하여 전문적인 작업을 위해 기본 에이전트에 의해 **자동으로** 호출됨.
- - 메시지에서 서브 에이전트를 **@멘션**. 예를 들어:
+2. subagent 호출 방법:
+ - **Automatically**: primary agent가 설명(description)을 바탕으로 특화 작업에 자동 호출합니다.
+ - 수동 호출: 메시지에서 subagent를 **@ mention**하여 호출합니다. 예:
```txt frame="none"
@general help me search for this function
```
-3. **세션 간 이동**: 서브 에이전트가 자체 자식 세션을 만들 때, 부모 세션과 자식 세션 간을 탐색할 수 있습니다.
- - **\+Right** (또는 부모 → 자식1 → 자식2 순으로 이동하기 위해 설정된 `session_child_cycle` 키바인드)
- - **\+Left** (또는 `session_child_cycle_reverse` 키바인드) 부모 방향으로 되돌아가기: 자식2 → 자식1 → 부모
+3. **세션 간 이동**: subagent가 child session을 만들면 아래 키로 parent session과 child session 사이를 이동할 수 있습니다.
+ - **\+Right** (또는 설정한 `session_child_cycle` keybind): parent → child1 → child2 → ... → parent 순방향 순환
+ - **\+Left** (또는 설정한 `session_child_cycle_reverse` keybind): parent ← child1 ← child2 ← ... ← parent 역방향 순환
- 이를 통해 주요 대화와 전문적인 서브 에이전트 작업 간을 원활하게 전환할 수 있습니다.
+ 이를 통해 메인 대화와 특화 subagent 작업 사이를 자연스럽게 오갈 수 있습니다.
---
## 구성
-내장 에이전트를 사용자 정의하거나 구성을 통해 자신만의 에이전트를 만들 수 있습니다. 에이전트는 두 가지 방법으로 구성될 수 있습니다:
+기본 제공 agent를 커스터마이즈하거나 config를 통해 직접 agent를 만들 수 있습니다. agent는 두 가지 방식으로 설정합니다.
---
### JSON
-`opencode.json` config 파일에 에이전트 구성:
+`opencode.json` config 파일에서 agent를 설정합니다.
```json title="opencode.json"
{
@@ -178,10 +178,10 @@ _모드_: `primary`
### Markdown
-Markdown 파일을 사용하여 에이전트를 정의 할 수 있습니다. 그들에 게:
+Markdown 파일로도 agent를 정의할 수 있습니다. 다음 위치에 두세요.
-- 글로벌: `~/.config/opencode/agents/`
-- 프로젝트: `.opencode/agents/`
+- Global: `~/.config/opencode/agents/`
+- Per-project: `.opencode/agents/`
```markdown title="~/.config/opencode/agents/review.md"
---
@@ -205,19 +205,19 @@ You are in code review mode. Focus on:
Provide constructive feedback without making direct changes.
```
-markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.md`는 `review` 에이전트을 만듭니다.
+Markdown 파일명은 agent 이름이 됩니다. 예를 들어 `review.md`는 `review` agent를 만듭니다.
---
## 옵션
-이 구성 옵션들을 자세히 살펴봅시다.
+각 config 옵션을 자세히 살펴보겠습니다.
---
-### 설명
+### Description
-`description` 옵션을 사용하여 에이전트가 무엇을 하고 언제 사용해야 하는지에 대한 간단한 설명을 제공합니다.
+`description` 옵션으로 agent의 역할과 사용 시점을 간단히 설명하세요.
```json title="opencode.json"
{
@@ -229,15 +229,15 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
}
```
-\*\* 필수 구성 옵션입니다.
+이 옵션은 **필수** config 항목입니다.
---
-### 온도
+### Temperature
-`temperature` 구성으로 LLM 응답의 무작위성과 창의성을 제어합니다.
+`temperature` config로 LLM 응답의 무작위성과 창의성을 제어합니다.
-값이 낮을수록 더 집중되고 결정적인 응답을 생성하며, 값이 높을수록 창의성과 가변성이 증가합니다.
+값이 낮을수록 응답이 더 집중되고 결정적이며, 값이 높을수록 창의성과 다양성이 커집니다.
```json title="opencode.json"
{
@@ -252,11 +252,11 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
}
```
-온도 값은 일반적으로 0.0에서 1.0 사이입니다:
+Temperature 값은 일반적으로 0.0~1.0 범위를 사용합니다.
-- **0.0-0.2**: 매우 집중되고 신중한 응답, 코드 분석 및 계획에 이상적
-- **0.3-0.5**: 창의성과 정확성의 균형, 일반 개발 작업에 좋음
-- **0.6-1.0**: 더 창의적이고 다양한 응답, 브레인스토밍 및 탐색에 유용함
+- **0.0-0.2**: 매우 집중되고 결정적인 응답, 코드 분석/계획에 적합
+- **0.3-0.5**: 적당한 창의성이 섞인 균형형 응답, 일반 개발 작업에 적합
+- **0.6-1.0**: 더 창의적이고 다양한 응답, 브레인스토밍/탐색에 유용
```json title="opencode.json"
{
@@ -276,15 +276,15 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
}
```
-온도가 지정되지 않은 경우, OpenCode는 모델별 기본값을 사용합니다. 일반적으로 대부분의 모델은 0, Qwen 모델의 경우 0.55입니다.
+temperature를 지정하지 않으면 OpenCode는 model별 기본값을 사용합니다. 일반적으로 대부분의 model은 0, Qwen model은 0.55를 사용합니다.
---
-## 최대 단계
+### Max steps
-에이전트가 중지하고 사용자와 다시 상호 작용하기 전에 실행할 수 있는 최대 단계 수를 제어합니다. 이를 통해 에이전트의 행동과 비용을 제어할 수 있습니다.
+agent가 텍스트 응답만 하도록 강제되기 전까지 수행할 수 있는 agentic iteration의 최대 횟수를 제어합니다. 비용을 관리하려는 사용자에게 agentic action 제한을 제공하기 위한 옵션입니다.
-설정되지 않은 경우, 에이전트는 모델이 중지하거나 사용자가 세션을 중단할 때까지 계속됩니다.
+이 값을 설정하지 않으면 model이 중단을 선택하거나 사용자가 세션을 중단할 때까지 agent는 계속 반복합니다.
```json title="opencode.json"
{
@@ -298,17 +298,17 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
}
```
-제한에 도달하면, 에이전트는 작업 요약과 권장되는 남은 작업을 신속하게 응답하도록 지시받습니다.
+제한에 도달하면 agent는 작업 요약과 남은 권장 작업을 응답하도록 지시하는 특수 시스템 prompt를 받습니다.
:::caution
-레거시 `maxSteps` 필드는 더 이상 사용되지 않습니다. 대신 `steps`를 사용하십시오.
+레거시 `maxSteps` 필드는 deprecated입니다. 대신 `steps`를 사용하세요.
:::
---
-#### 비활성화
+### Disable
-`true`로 설정하여 에이전트를 비활성화합니다.
+`true`로 설정하면 agent를 비활성화합니다.
```json title="opencode.json"
{
@@ -322,9 +322,9 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
---
-#### 프롬프트
+### Prompt
-`prompt` 구성으로 이 에이전트를 위한 사용자 정의 시스템 프롬프트 파일을 지정하십시오. 프롬프트 파일은 에이전트의 목적에 따른 지시를 포함해야 합니다.
+`prompt` config로 해당 agent의 custom 시스템 prompt 파일을 지정합니다. prompt 파일에는 agent 목적에 맞는 지시사항을 작성하세요.
```json title="opencode.json"
{
@@ -336,16 +336,16 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
}
```
-이 경로는 구성 파일이 있는 위치를 기준으로 합니다. 따라서 글로벌 OpenCode 구성과 프로젝트별 구성 모두에서 작동합니다.
+이 경로는 config 파일 위치 기준의 상대 경로입니다. 따라서 전역 OpenCode config와 프로젝트별 config 모두에서 동일하게 동작합니다.
---
-### 모델
+### Model
-`model` 구성을 사용하여 이 에이전트에 대한 모델을 재정의합니다. 다른 작업에 최적화된 다른 모델을 사용하는 데 유용합니다. 예를 들어, 계획을 위한 빠른 모델, 구현을 위한 더 강력한 모델 등입니다.
+`model` config로 해당 agent의 model을 override할 수 있습니다. 작업 특성에 맞춰 model을 달리 쓸 때 유용합니다. 예를 들어 계획에는 더 빠른 model, 구현에는 더 강력한 model을 사용할 수 있습니다.
:::tip
-모델을 지정하지 않는 경우, 기본 에이전트는 [전역 구성 모델](/docs/config#models)을 사용하며, 서브 에이전트는 자신을 호출한 기본 에이전트의 모델을 사용합니다.
+model을 지정하지 않으면 primary agent는 [전역으로 설정한 model](/docs/config#models)을 사용하고, subagent는 해당 subagent를 호출한 primary agent의 model을 사용합니다.
:::
```json title="opencode.json"
@@ -358,13 +358,13 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m
}
```
-OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다. 예를 들어, [OpenCode Zen](/docs/zen)을 사용한다면, GPT 5.1 Codex에 `opencode/gpt-5.1-codex`를 사용할 수 있습니다.
+OpenCode config의 model ID는 `provider/model-id` 형식을 사용합니다. 예를 들어 [OpenCode Zen](/docs/zen)을 사용한다면 GPT 5.1 Codex에 `opencode/gpt-5.1-codex`를 사용합니다.
---
-## 도구
+### Tools
-`tools` 구성으로 이 에이전트가 사용할 수 있는 도구를 제어합니다. `true` 또는 `false`로 설정하여 특정 도구를 활성화하거나 비활성화할 수 있습니다.
+`tools` config로 agent에서 사용할 tool을 제어합니다. 각 tool을 `true` 또는 `false`로 설정해 활성화/비활성화할 수 있습니다.
```json title="opencode.json" {3-6,9-12}
{
@@ -385,10 +385,10 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다.
```
:::note
-에이전트별 구성은 글로벌 구성을 덮어씁니다.
+agent별 config는 전역 config를 override합니다.
:::
-한 번에 여러 도구를 제어하기 위해 와일드카드를 사용할 수 있습니다. 예를 들어, MCP 서버에서 모든 도구를 비활성화하려면:
+와일드카드를 사용하면 여러 tool을 한 번에 제어할 수 있습니다. 예를 들어 MCP 서버의 모든 tool을 비활성화하려면 다음과 같이 설정합니다.
```json title="opencode.json"
{
@@ -405,17 +405,17 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다.
}
```
-[도구에 대해 더 알아보기](/docs/tools).
+[tool에 대해 더 알아보기](/docs/tools).
---
-## 권한
+### Permissions
-에이전트가 수행할 수 있는 작업을 관리할 수 있는 권한을 구성할 수 있습니다. 현재 `edit`, `bash` 및 `webfetch` 도구에 대한 권한은 다음과 같습니다.
+permission을 설정해 agent가 수행할 수 있는 action을 제어할 수 있습니다. 현재 `edit`, `bash`, `webfetch` tool의 permission은 다음 값으로 설정할 수 있습니다.
-- `"ask"` - 도구를 실행하기 전에 승인 요청
-- `"allow"` - 승인 없이 모든 작업 허용
-- `"deny"` - 도구 비활성화
+- `"ask"` — tool 실행 전에 승인 요청
+- `"allow"` — 승인 없이 모든 작업 허용
+- `"deny"` — tool 비활성화
```json title="opencode.json"
{
@@ -426,7 +426,7 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다.
}
```
-에이전트별로 이 권한을 재정의(override)할 수 있습니다.
+이 permission은 agent별로 override할 수 있습니다.
```json title="opencode.json" {3-5,8-10}
{
@@ -444,7 +444,7 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다.
}
```
-Markdown 에이전트에서도 권한을 설정할 수 있습니다.
+Markdown agent에서도 permission을 설정할 수 있습니다.
```markdown title="~/.config/opencode/agents/review.md"
---
@@ -463,7 +463,7 @@ permission:
Only analyze code and suggest changes.
```
-특정 bash 명령에 대한 권한을 설정할 수 있습니다.
+특정 bash 명령에 대해서도 permission을 설정할 수 있습니다.
```json title="opencode.json" {7}
{
@@ -481,7 +481,7 @@ Only analyze code and suggest changes.
}
```
-이것은 glob 패턴을 사용할 수 있습니다.
+여기에는 glob 패턴을 사용할 수 있습니다.
```json title="opencode.json" {7}
{
@@ -498,8 +498,8 @@ Only analyze code and suggest changes.
}
```
-또한 `*` 와일드카드를 사용하여 모든 명령에 대한 권한을 관리할 수 있습니다.
-마지막 일치 규칙이 우선하므로, `*` 와일드카드를 먼저 두고 특정 규칙을 나중에 두십시오.
+또한 `*` 와일드카드로 모든 명령의 permission을 제어할 수 있습니다.
+마지막으로 일치한 규칙이 우선하므로 `*` 와일드카드를 먼저 두고, 구체적인 규칙을 뒤에 두세요.
```json title="opencode.json" {8}
{
@@ -517,13 +517,13 @@ Only analyze code and suggest changes.
}
```
-[권한에 대해 더 알아보기](/docs/permissions).
+[permission에 대해 더 알아보기](/docs/permissions).
---
-### 모드
+### Mode
-`mode` 구성으로 에이전트 모드를 제어합니다. `mode` 옵션은 에이전트가 어떻게 사용될 수 있는지 결정하는 데 사용됩니다.
+`mode` config로 agent 모드를 제어합니다. `mode` 옵션은 agent를 어떤 방식으로 사용할지 결정합니다.
```json title="opencode.json"
{
@@ -535,13 +535,13 @@ Only analyze code and suggest changes.
}
```
-`mode` 옵션은 `primary`, `subagent`, 또는 `all`로 설정할 수 있습니다. `mode`가 지정되지 않은 경우 `all`이 기본값입니다.
+`mode`는 `primary`, `subagent`, `all` 중 하나로 설정할 수 있습니다. 설정하지 않으면 기본값은 `all`입니다.
---
-## 숨김
+### Hidden
-`hidden: true`를 사용하여 `@` 자동 완성 메뉴에서 에이전트를 숨깁니다. 작업 도구를 통해 다른 에이전트에 의해 프로그래밍 방식으로 호출되어야 하는 내부 에이전트에 유용합니다.
+`hidden: true`를 설정하면 `@` 자동완성 메뉴에서 subagent를 숨길 수 있습니다. 다른 agent가 Task tool을 통해 programmatic으로만 호출해야 하는 내부 subagent에 유용합니다.
```json title="opencode.json"
{
@@ -554,17 +554,17 @@ Only analyze code and suggest changes.
}
```
-자동 완성 메뉴의 사용자 가시성에만 영향을 미칩니다. 숨겨진 에이전트는 권한이 허용된다면 여전히 작업 도구를 통해 모델에 의해 호출될 수 있습니다.
+이 설정은 자동완성 메뉴에서의 사용자 가시성에만 영향을 줍니다. permission이 허용되면 hidden agent도 모델이 Task tool을 통해 호출할 수 있습니다.
:::note
-`mode: subagent` 에이전트에만 적용됩니다.
+`mode: subagent` agent에만 적용됩니다.
:::
---
-## 작업 권한
+### Task permissions
-`permission.task`와 작업 도구를 통해 에이전트가 호출할 수 있는 서브 에이전트를 제어합니다. 유연한 일치를 위한 glob 패턴을 사용합니다.
+`permission.task`로 Task tool을 통해 해당 agent가 호출할 수 있는 subagent 범위를 제어합니다. 유연한 매칭을 위해 glob 패턴을 사용합니다.
```json title="opencode.json"
{
@@ -583,23 +583,23 @@ Only analyze code and suggest changes.
}
```
-`deny`로 설정할 때, 서브 에이전트는 작업 도구 설명에서 완전히 제거됩니다. 따라서 모델은 그것을 호출하려고 시도하지 않습니다.
+`deny`로 설정되면 해당 subagent는 Task tool 설명에서 완전히 제거되므로 모델이 호출을 시도하지 않습니다.
:::tip
-규칙은 순서대로 평가되며, **마지막 일치 규칙**이 우선합니다. 위의 예에서 `orchestrator-planner`는 `*` (deny)와 `orchestrator-*` (allow) 모두 일치하지만 `orchestrator-*`가 `*` 뒤에 오므로 결과는 `allow`입니다.
+규칙은 선언 순서대로 평가되며, **마지막으로 일치한 규칙이 승리합니다**. 위 예시에서 `orchestrator-planner`는 `*`(deny)와 `orchestrator-*`(allow) 모두에 일치하지만, `orchestrator-*`가 뒤에 있으므로 결과는 `allow`입니다.
:::
:::tip
-사용자는 에이전트의 작업 권한이 거부되더라도 `@` 자동 완성 메뉴를 통해 직접 어떤 서브 에이전트든 호출할 수 있습니다.
+사용자는 agent의 task permission이 deny여도 `@` 자동완성 메뉴를 통해 어떤 subagent든 직접 호출할 수 있습니다.
:::
---
-### 색상
+### Color
-`color` 옵션으로 UI에서 에이전트의 시각적 모양을 사용자 정의합니다.
+`color` 옵션으로 UI에서 agent의 시각 스타일을 지정할 수 있습니다. 인터페이스에서 agent가 표시되는 방식에 영향을 줍니다.
-유효한 hex 색상(예: `#FF5733`) 또는 테마 색상을 사용하십시오: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`.
+유효한 hex 색상(예: `#FF5733`) 또는 theme 색상(`primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`)을 사용하세요.
```json title="opencode.json"
{
@@ -618,7 +618,7 @@ Only analyze code and suggest changes.
### Top P
-`top_p` 옵션으로 응답의 다양성을 제어합니다. 무작위성 제어를 위한 온도의 대안입니다.
+`top_p` 옵션으로 응답 다양성을 제어합니다. 무작위성을 제어하는 Temperature의 대안입니다.
```json title="opencode.json"
{
@@ -630,15 +630,15 @@ Only analyze code and suggest changes.
}
```
-값은 0.0에서 1.0 사이입니다. 낮은 값은 더 집중되고, 높은 값은 더 다양합니다.
+값 범위는 0.0~1.0입니다. 값이 낮을수록 집중되고, 높을수록 다양해집니다.
---
-### 추가 옵션
+### Additional
-에이전트 구성에 지정하는 다른 옵션은 모델 옵션으로 공급자에게 **직접 전달**됩니다. 이를 통해 공급자별 기능 및 매개변수를 사용할 수 있습니다.
+agent config에 지정한 나머지 옵션은 모델 옵션으로 provider에 **그대로 전달(pass through)** 됩니다. 이를 통해 provider별 기능과 파라미터를 활용할 수 있습니다.
-예를 들어, OpenAI의 추론 모델과 함께, 추론 노력을 제어할 수 있습니다:
+예를 들어 OpenAI reasoning model에서는 reasoning effort를 제어할 수 있습니다.
```json title="opencode.json" {6,7}
{
@@ -653,55 +653,55 @@ Only analyze code and suggest changes.
}
```
-이 추가 옵션은 모델과 공급자별로 다릅니다. 사용 가능한 매개변수는 공급자의 문서를 확인하십시오.
+이 추가 옵션은 model 및 provider마다 다릅니다. 사용 가능한 파라미터는 provider 문서를 확인하세요.
:::tip
-`opencode models`를 실행하여 사용 가능한 모델 목록을 볼 수 있습니다.
+사용 가능한 model 목록은 `opencode models` 명령으로 확인할 수 있습니다.
:::
---
-## 에이전트 만들기
+## 에이전트 생성
-다음 명령을 사용하여 새로운 에이전트를 만들 수 있습니다:
+아래 명령으로 새 agent를 만들 수 있습니다.
```bash
opencode agent create
```
-이 대화형 명령은:
+이 인터랙티브 명령은 다음을 수행합니다.
-1. 에이전트를 저장할 위치를 묻습니다 (전역 또는 프로젝트별).
-2. 에이전트가 해야 할 일에 대한 설명을 묻습니다.
-3. 적절한 시스템 프롬프트 및 식별자를 생성합니다.
-4. 에이전트가 접근할 수 있는 도구를 선택하게 합니다.
-5. 마지막으로, 에이전트 구성을 가진 markdown 파일을 생성합니다.
+1. agent 저장 위치를 묻습니다(전역/프로젝트).
+2. agent가 수행할 작업의 설명을 받습니다.
+3. 적절한 시스템 prompt와 식별자를 생성합니다.
+4. agent가 접근할 tool을 선택하게 합니다.
+5. 마지막으로 agent config가 담긴 Markdown 파일을 생성합니다.
---
## 사용 사례
-다른 에이전트를 위한 몇 가지 일반적인 사용 사례는 다음과 같습니다.
+서로 다른 agent의 대표적인 사용 사례는 다음과 같습니다.
-- **Build Agent**: 모든 도구와 함께 전체 개발 작업
-- **Plan Agent**: 변경 없이 분석 및 계획
-- **Review Agent**: 읽기 전용 액세스 및 문서 도구와 함께 코드 리뷰
-- **Debug Agent**: bash 및 읽기 도구와 함께 조사에 집중
-- **Docs Agent**: 파일 작업과 문서 작성을 하지만 시스템 명령 없음
+- **Build agent**: 모든 tool을 활성화한 전체 개발 작업
+- **Plan agent**: 코드 변경 없이 분석과 계획 수행
+- **Review agent**: 읽기 전용 접근 + 문서화 tool 기반 코드 리뷰
+- **Debug agent**: bash/read tool 중심의 조사 작업
+- **Docs agent**: 파일 작업은 가능하지만 시스템 명령은 없는 문서 작성 작업
---
-## 예제
+## 예시
-여기에 유용 할 수있는 몇 가지 예 에이전트가 있습니다.
+실제로 유용하게 쓸 수 있는 예시 agent를 소개합니다.
:::tip
-공유하고 싶은 에이전트가 있습니까? [PR](https://github.com/anomalyco/opencode).
+공유하고 싶은 agent가 있나요? [PR 제출하기](https://github.com/anomalyco/opencode).
:::
---
-### 문서 에이전트
+### Documentation agent
```markdown title="~/.config/opencode/agents/docs-writer.md"
---
@@ -723,7 +723,7 @@ Focus on:
---
-## 보안 감사
+### Security auditor
```markdown title="~/.config/opencode/agents/security-auditor.md"
---
diff --git a/packages/web/src/content/docs/ko/config.mdx b/packages/web/src/content/docs/ko/config.mdx
index 0357ded78575..e906eaf47b22 100644
--- a/packages/web/src/content/docs/ko/config.mdx
+++ b/packages/web/src/content/docs/ko/config.mdx
@@ -1,15 +1,15 @@
---
-title: 구성
-description: OpenCode JSON 구성을 사용합니다.
+title: Config
+description: Using the OpenCode JSON config.
---
-JSON 구성 파일을 사용하여 OpenCode를 구성할 수 있습니다.
+JSON config 파일로 OpenCode를 설정할 수 있습니다.
---
## 형식
-OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합니다.
+OpenCode는 **JSON**과 **JSONC**(주석이 포함된 JSON) 형식을 모두 지원합니다.
```jsonc title="opencode.jsonc"
{
@@ -25,44 +25,44 @@ OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합
## 위치
-구성을 여러 위치에 배치할 수 있으며, 이들은 서로 다른 우선 순위(precedence)를 가집니다.
+config 파일은 여러 위치에 둘 수 있으며, 각 위치에는 우선순위가 있습니다.
:::note
-구성 파일은 **병합**되며, 대체되지 않습니다.
+config 파일은 **교체되지 않고 병합**됩니다.
:::
-구성 파일은 함께 병합되며 대체되지 않습니다. 다음 구성 위치의 설정이 결합됩니다. 나중의 구성은 충돌하는 키에 대해 이전 구성을 덮어씁니다. 모든 구성의 설정이 보존됩니다.
+config 파일은 서로 대체되는 방식이 아니라 병합됩니다. 아래 config 위치의 설정이 결합되며, 충돌하는 key에 대해서만 나중에 로드된 config가 앞선 값을 override합니다. 충돌하지 않는 설정은 모두 유지됩니다.
-예를 들어, 전역 구성이 `theme: "opencode"` 및 `autoupdate: true`를 설정하고 프로젝트 구성이 `model: "anthropic/claude-sonnet-4-5"`를 설정하면 최종 구성에는 세 가지 설정이 모두 포함됩니다.
+예를 들어, 전역 config에 `theme: "opencode"`와 `autoupdate: true`가 있고 프로젝트 config에 `model: "anthropic/claude-sonnet-4-5"`가 있으면 최종 config에는 이 세 설정이 모두 포함됩니다.
---
-### 우선 순위
+### 우선순위
-구성 소스는 다음 순서로 로드됩니다 (나중 소스가 이전 소스를 덮어씀):
+config source는 다음 순서로 로드됩니다(나중 source가 앞선 source를 override).
-1. **원격 구성** (`.well-known/opencode`에서) - 조직 기본값
-2. **전역 구성** (`~/.config/opencode/opencode.json`) - 사용자 환경설정
-3. **사용자 정의 구성** (`OPENCODE_CONFIG` 환경 변수) - 사용자 정의 재정의
-4. **프로젝트별 구성** (`opencode.json`) - 프로젝트별 설정
-5. **`.opencode` 디렉토리** - 에이전트, 명령, 플러그인
-6. **인라인 구성** (`OPENCODE_CONFIG_CONTENT` 환경 변수) - 런타임 재정의
+1. **Remote config**(`.well-known/opencode`) - 조직 기본값
+2. **Global config**(`~/.config/opencode/opencode.json`) - 사용자 기본 설정
+3. **Custom config**(`OPENCODE_CONFIG` env var) - custom override
+4. **Project config**(프로젝트의 `opencode.json`) - 프로젝트별 설정
+5. **`.opencode` directories** - agents, commands, plugins
+6. **Inline config**(`OPENCODE_CONFIG_CONTENT` env var) - 런타임 override
-이것은 프로젝트 구성이 전역 기본값을 덮어쓸 수 있고, 전역 구성이 원격 조직 기본값을 덮어쓸 수 있음을 의미합니다.
+즉, 프로젝트 config는 전역 기본값을 override할 수 있고, 전역 config는 조직의 Remote 기본값을 override할 수 있습니다.
:::note
-`.opencode`와 `~/.config/opencode` 디렉토리는 하위 디렉토리에 대해 **복수형 이름**을 사용합니다: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, 그리고 `themes/`. 단수형 이름(예: `agent/`)도 하위 호환성을 위해 지원됩니다.
+`.opencode` 및 `~/.config/opencode` 디렉토리는 하위 디렉토리에 **복수형 이름**을 사용합니다: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, `themes/`. 단수형 이름(예: `agent/`)도 하위 호환성을 위해 지원합니다.
:::
---
-### 원격
+### Remote
-조직은 `.well-known/opencode` 엔드포인트를 통해 기본 구성을 제공할 수 있습니다. 이를 지원하는 공급자로 인증할 때 자동으로 가져옵니다.
+조직은 `.well-known/opencode` endpoint로 기본 config를 제공할 수 있습니다. 이를 지원하는 provider로 인증하면 자동으로 가져옵니다.
-원격 구성은 기본 레이어로 가장 먼저 로드됩니다. 다른 구성 소스(전역, 프로젝트)는 이러한 기본값을 무시(override)할 수 있습니다.
+Remote config는 가장 먼저 로드되어 기본 레이어 역할을 합니다. 이후의 모든 config source(전역, 프로젝트)는 이 기본값을 override할 수 있습니다.
-예를 들어, 조직이 기본적으로 비활성화된 MCP 서버를 제공하는 경우:
+예를 들어, 조직에서 기본 비활성화된 MCP 서버를 제공하는 경우:
```json title="Remote config from .well-known/opencode"
{
@@ -76,7 +76,7 @@ OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합
}
```
-로컬 설정에서 특정 서버를 활성화할 수 있습니다:
+로컬 config에서 특정 서버를 활성화할 수 있습니다.
```json title="opencode.json"
{
@@ -92,65 +92,65 @@ OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합
---
-## 전역
+### Global
-`~/.config/opencode/opencode.json`에 전역 OpenCode 구성을 배치합니다. 테마, 공급자, 키바인드와 같은 사용자 전체 기본 설정에 전역 구성을 사용하십시오.
+전역 OpenCode config는 `~/.config/opencode/opencode.json`에 두세요. theme, provider, keybind 같은 사용자 전체 기본 설정은 전역 config로 관리하세요.
-전역 구성은 원격 조직 기본값을 덮어씁니다.
+전역 config는 조직의 Remote 기본값을 override합니다.
---
-## 프로젝트별
+### Per project
-프로젝트 루트에 `opencode.json`을 추가합니다. 프로젝트 구성은 표준 구성 파일 중 가장 높은 우선순위를 가집니다. 이는 전역 및 원격 구성을 모두 덮어씁니다.
+프로젝트 루트에 `opencode.json`을 추가하세요. 프로젝트 config는 표준 config 파일 중 우선순위가 가장 높아 전역 및 Remote config를 모두 override합니다.
:::tip
-프로젝트의 루트에 특정 설정을 둡니다.
+프로젝트별 config는 프로젝트 루트에 두세요.
:::
-OpenCode가 시작될 때, 현재 디렉토리의 설정 파일이나 가장 가까운 Git 디렉토리를 찾습니다.
+OpenCode 시작 시 현재 디렉토리에서 config 파일을 찾고, 없으면 가장 가까운 Git 디렉토리까지 상위로 탐색합니다.
-이것은 Git으로 관리되며 전역 구성과 동일한 스키마를 사용합니다.
+이 파일은 Git에 커밋해도 안전하며 전역 config와 동일한 schema를 사용합니다.
---
-### 사용자 정의 경로
+### Custom path
-`OPENCODE_CONFIG` 환경 변수를 사용하여 사용자 정의 구성 파일 경로를 지정합니다.
+`OPENCODE_CONFIG` 환경 변수로 custom config 파일 경로를 지정하세요.
```bash
export OPENCODE_CONFIG=/path/to/my/custom-config.json
opencode run "Hello world"
```
-사용자 정의 구성은 우선 순위에서 전역 구성과 프로젝트 구성 사이에 로드됩니다.
+Custom config는 우선순위상 전역 config와 프로젝트 config 사이에서 로드됩니다.
---
-## 사용자 정의 디렉토리
+### Custom directory
-`OPENCODE_CONFIG_DIR` 환경 변수를 사용하여 사용자 정의 구성 디렉토리를 지정할 수 있습니다. 이 디렉토리는 표준 `.opencode` 디렉토리와 마찬가지로 에이전트, 명령, 모드 및 플러그인을 검색하며 동일한 구조를 따라야 합니다.
+`OPENCODE_CONFIG_DIR` 환경 변수로 custom config 디렉토리를 지정할 수 있습니다. 이 디렉토리는 표준 `.opencode` 디렉토리와 동일하게 agents, commands, modes, plugins를 검색하며, 동일한 구조를 따라야 합니다.
```bash
export OPENCODE_CONFIG_DIR=/path/to/my/config-directory
opencode run "Hello world"
```
-사용자 정의 디렉토리는 전역 구성 이후 및 `.opencode` 디렉토리 이전에 로드됩니다.
+custom 디렉토리는 전역 config와 `.opencode` 디렉토리 뒤에 로드되므로 해당 설정을 **override할 수 있습니다**.
---
-## 스키마
+## Schema
-구성 파일에는 [**`opencode.ai/config.json`**](https://opencode.ai/config.json)에 정의된 스키마가 있습니다.
+config 파일의 schema는 [**`opencode.ai/config.json`**](https://opencode.ai/config.json)에 정의되어 있습니다.
-편집기는 스키마에 따라 유효성 검사 및 자동 완성을 제공해야 합니다.
+편집기에서 이 schema를 기반으로 validation과 autocomplete를 사용할 수 있습니다.
---
-#### TUI
+### TUI
-`tui` 옵션을 통해 TUI 관련 설정을 구성할 수 있습니다.
+`tui` 옵션으로 TUI 관련 설정을 구성할 수 있습니다.
```json title="opencode.json"
{
@@ -165,19 +165,19 @@ opencode run "Hello world"
}
```
-유효한 옵션:
+사용 가능한 옵션:
- `scroll_acceleration.enabled` - macOS 스타일 스크롤 가속을 활성화합니다. **`scroll_speed`보다 우선합니다.**
-- `scroll_speed` - 사용자 정의 스크롤 속도 배수 (기본값: `3`, 최소값: `1`). `scroll_acceleration.enabled`가 `true`이면 무시됩니다.
-- `diff_style` - diff 렌더링을 제어합니다. `"auto"`는 터미널 너비에 맞추고, `"stacked"`는 항상 단일 열을 보여줍니다.
+- `scroll_speed` - 사용자 정의 스크롤 속도 배수(기본: `3`, 최소: `1`). `scroll_acceleration.enabled`가 `true`이면 무시됩니다.
+- `diff_style` - diff 렌더링 방식을 제어합니다. `"auto"`는 터미널 너비에 맞춰 조정되고, `"stacked"`는 항상 단일 컬럼으로 표시합니다.
-[TUI 사용법에 대해 더 알아보기](/docs/tui).
+[TUI에 대해 더 알아보기](/docs/tui).
---
-## 서버
+### Server
-`opencode serve` 및 `opencode web` 명령에 대한 서버 설정을 구성할 수 있습니다.
+`server` 옵션으로 `opencode serve`와 `opencode web` 명령의 server 설정을 구성할 수 있습니다.
```json title="opencode.json"
{
@@ -192,21 +192,21 @@ opencode run "Hello world"
}
```
-유효한 옵션:
+사용 가능한 옵션:
-- `port` - 리스닝 포트.
-- `hostname` - 리스닝 호스트 이름. `mdns`가 활성화되고 hostname이 설정되지 않으면 `0.0.0.0`이 기본값이 됩니다.
-- `mdns` - mDNS 서비스 발견 활성화. 로컬 네트워크의 다른 장치가 OpenCode 서버를 찾을 수 있습니다.
-- `mdnsDomain` - mDNS 서비스를 위한 사용자 정의 도메인 이름. 기본값은 `opencode.local`입니다. 동일한 네트워크에서 여러 인스턴스를 실행할 때 유용합니다.
-- `cors` - 브라우저 기반 클라이언트에서 HTTP 서버를 사용할 때 CORS를 허용할 추가 출처(Origin). 값은 전체 출처(스킴 + 호스트 + 선택적 포트)여야 합니다. 예: `https://app.example.com`.
+- `port` - 수신할 포트입니다.
+- `hostname` - 수신할 호스트명입니다. `mdns`가 활성화되어 있고 hostname이 없으면 기본값은 `0.0.0.0`입니다.
+- `mdns` - mDNS service discovery를 활성화합니다. 네트워크 내 다른 기기에서 OpenCode server를 찾을 수 있습니다.
+- `mdnsDomain` - mDNS service의 custom 도메인 이름입니다. 기본값은 `opencode.local`입니다. 같은 네트워크에서 여러 인스턴스를 실행할 때 유용합니다.
+- `cors` - 브라우저 기반 client에서 HTTP server를 사용할 때 허용할 추가 CORS origin입니다. 값은 전체 origin(scheme + host + optional port) 형식이어야 하며, 예: `https://app.example.com`.
-[서버에 대해 더 알아보기](/docs/server).
+[server에 대해 더 알아보기](/docs/server).
---
-## 도구
+### Tools
-`tools` 옵션을 통해 LLM이 사용할 수 있는 도구를 구성할 수 있습니다.
+`tools` 옵션으로 LLM이 사용할 수 있는 tool을 관리할 수 있습니다.
```json title="opencode.json"
{
@@ -218,13 +218,13 @@ opencode run "Hello world"
}
```
-[도구에 대해 더 알아보기](/docs/tools).
+[tool에 대해 더 알아보기](/docs/tools).
---
-## 모델
+### Models
-`provider`, `model`, `small_model` 옵션을 통해 OpenCode 구성에서 사용할 공급자와 모델을 구성할 수 있습니다.
+OpenCode config의 `provider`, `model`, `small_model` 옵션으로 사용할 provider와 model을 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -235,9 +235,9 @@ opencode run "Hello world"
}
```
-`small_model` 옵션은 제목 생성과 같은 가벼운 작업을 위한 별도의 모델을 구성합니다. 기본적으로, OpenCode는 공급자에게서 사용 가능한 더 저렴한 모델이 있다면 그것을 사용하고, 그렇지 않으면 주 모델로 돌아갑니다.
+`small_model`은 제목 생성 같은 경량 작업에 사용할 별도 model을 설정합니다. 기본적으로 OpenCode는 provider에서 더 저렴한 model을 사용할 수 있으면 우선 사용하고, 없으면 메인 model로 fallback합니다.
-공급자 옵션은 `timeout`과 `setCacheKey`를 포함할 수 있습니다:
+provider 옵션에는 `timeout`, `setCacheKey`를 포함할 수 있습니다.
```json title="opencode.json"
{
@@ -253,20 +253,20 @@ opencode run "Hello world"
}
```
-- `timeout` - 요청 타임아웃(밀리초) (기본값: 300000). `false`로 설정하여 비활성화할 수 있습니다.
-- `setCacheKey` - 지정된 공급자에 대해 캐시 키가 항상 설정되도록 강제합니다.
+- `timeout` - 요청 timeout(밀리초, 기본값: 300000). `false`로 비활성화할 수 있습니다.
+- `setCacheKey` - 지정된 provider에 대해 cache key가 항상 설정되도록 보장합니다.
-[로컬 모델](/docs/models#local)을 구성할 수도 있습니다. [더 알아보기](/docs/models).
+[local model](/docs/models#local)도 설정할 수 있습니다. [더 알아보기](/docs/models).
---
-### 공급자별 옵션
+#### Provider-Specific Options
-일반적인 `timeout` 및 `apiKey` 외에도 일부 공급자는 추가 구성 옵션을 지원합니다.
+일부 provider는 공통 옵션인 `timeout`, `apiKey` 외에 추가 config 옵션을 지원합니다.
##### Amazon Bedrock
-Amazon Bedrock는 AWS 관련 구성을 지원합니다:
+Amazon Bedrock은 AWS 전용 config를 지원합니다.
```json title="opencode.json"
{
@@ -283,21 +283,21 @@ Amazon Bedrock는 AWS 관련 구성을 지원합니다:
}
```
-- `region` - Bedrock를 위한 AWS 리전 (`AWS_REGION` 환경 변수 또는 `us-east-1`이 기본값)
-- `profile` - `~/.aws/credentials`의 AWS 프로필 이름 (`AWS_PROFILE` 환경 변수가 기본값)
-- `endpoint` - VPC 엔드포인트 등을 위한 사용자 정의 엔드포인트 URL. 이는 AWS 관련 용어를 사용한 일반적인 `baseURL` 옵션의 별칭입니다. 둘 다 지정된 경우 `endpoint`가 우선합니다.
+- `region` - Bedrock용 AWS 리전(`AWS_REGION` env var 또는 기본값 `us-east-1`)
+- `profile` - `~/.aws/credentials`의 AWS named profile(기본값: `AWS_PROFILE` env var)
+- `endpoint` - VPC endpoint용 custom endpoint URL입니다. AWS 용어를 사용한 일반 `baseURL` 옵션의 별칭(alias)입니다. 둘 다 지정하면 `endpoint`가 우선합니다.
:::note
-Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 인증보다 우선합니다. 자세한 내용은 [인증 우선 순위](/docs/providers#authentication-precedence)를 참조하십시오.
+Bearer token(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 profile 기반 인증보다 우선합니다. 자세한 내용은 [authentication precedence](/docs/providers#authentication-precedence)를 참고하세요.
:::
-[Amazon Bedrock에 대해 더 알아보기](/docs/providers#amazon-bedrock).
+[Amazon Bedrock config 더 알아보기](/docs/providers#amazon-bedrock).
---
-## 테마
+### Themes
-`theme` 옵션을 통해 OpenCode 구성에서 사용할 테마를 설정할 수 있습니다.
+`theme` 옵션으로 OpenCode config에서 사용할 theme를 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -310,9 +310,9 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
---
-## 에이전트
+### Agents
-`agent` 옵션을 통해 특정 작업을 전문으로 하는 에이전트를 구성할 수 있습니다.
+`agent` 옵션으로 특정 작업용 전문 agent를 구성할 수 있습니다.
```jsonc title="opencode.jsonc"
{
@@ -332,13 +332,13 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
}
```
-`~/.config/opencode/agents/` 또는 `.opencode/agents/`에서 Markdown 파일을 사용하여 에이전트를 정의할 수도 있습니다. [더 알아보기](/docs/agents).
+`~/.config/opencode/agents/` 또는 `.opencode/agents/`의 Markdown 파일로 agent를 정의할 수도 있습니다. [더 알아보기](/docs/agents).
---
-### 기본 에이전트
+### Default agent
-`default_agent` 옵션을 사용하여 기본 에이전트를 설정할 수 있습니다. 명시적으로 지정되지 않았을 때 어떤 에이전트가 사용될지 결정합니다.
+`default_agent` 옵션으로 기본 agent를 설정할 수 있습니다. 별도 지정이 없을 때 어떤 agent를 사용할지 결정합니다.
```json title="opencode.json"
{
@@ -347,15 +347,15 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
}
```
-기본 에이전트는 기본(primary) 에이전트여야 합니다(서브 에이전트 불가). `"build"` 또는 `"plan"`과 같은 내장 에이전트이거나 정의된 [사용자 정의 에이전트](./agents)일 수 있습니다. 지정된 에이전트가 존재하지 않는 경우, OpenCode는 경고와 함께 `"build"`로 돌아갑니다.
+기본 agent는 primary agent여야 합니다(subagent 불가). `"build"`, `"plan"` 같은 내장 agent나 직접 정의한 [custom agent](/docs/agents)를 지정할 수 있습니다. 지정한 agent가 없거나 subagent이면 OpenCode는 경고와 함께 `"build"`로 fallback합니다.
-이 설정은 모든 인터페이스에 적용됩니다: TUI, CLI (`opencode run`), 데스크톱 앱 및 GitHub Action.
+이 설정은 TUI, CLI(`opencode run`), 데스크톱 앱, GitHub Action 등 모든 인터페이스에 적용됩니다.
---
-## 공유
+### Sharing
-`share` 옵션을 통해 [공유](/docs/share) 기능을 구성할 수 있습니다.
+`share` 옵션으로 [share](/docs/share) 기능을 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -364,19 +364,19 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
}
```
-값은 다음과 같습니다:
+허용 값:
-- `"manual"` - 명령을 통한 수동 공유 허용 (기본값)
-- `"auto"` - 새로운 대화를 자동으로 공유
-- `"disabled"` - 공유 기능 완전히 비활성화
+- `"manual"` - 명령으로 수동 공유 허용(기본값)
+- `"auto"` - 새 대화를 자동 공유
+- `"disabled"` - 공유 기능 완전 비활성화
-기본적으로 `/share` 명령을 사용하여 대화를 명시적으로 공유해야 하는 수동 모드로 설정됩니다.
+기본값은 manual 모드이며, `/share` 명령으로 명시적으로 공유해야 합니다.
---
-## 명령
+### Commands
-`command` 옵션을 통해 반복 작업을 위한 사용자 정의 명령을 구성할 수 있습니다.
+`command` 옵션으로 반복 작업용 custom command를 구성할 수 있습니다.
```jsonc title="opencode.jsonc"
{
@@ -387,8 +387,6 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
"description": "Run tests with coverage",
"agent": "build",
"model": "anthropic/claude-haiku-4-5",
- "agent": "build",
- "model": "anthropic/claude-haiku-4-5",
},
"component": {
"template": "Create a new React component named $ARGUMENTS with TypeScript support.\nInclude proper typing and basic structure.",
@@ -398,13 +396,13 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
}
```
-`~/.config/opencode/commands/` 또는 `.opencode/commands/`에서 Markdown 파일을 사용하여 명령을 정의할 수도 있습니다. [더 알아보기](/docs/commands).
+`~/.config/opencode/commands/` 또는 `.opencode/commands/`의 Markdown 파일로 command를 정의할 수도 있습니다. [더 알아보기](/docs/commands).
---
-## 키바인드
+### Keybinds
-`keybinds` 옵션을 통해 키바인드를 사용자 정의할 수 있습니다.
+`keybinds` 옵션으로 keybind를 커스터마이즈할 수 있습니다.
```json title="opencode.json"
{
@@ -417,9 +415,9 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반
---
-## 자동 업데이트
+### Autoupdate
-OpenCode는 시작될 때 자동으로 새로운 업데이트를 다운로드합니다. `autoupdate` 옵션으로 이를 비활성화할 수 있습니다.
+OpenCode는 시작 시 새 업데이트를 자동으로 다운로드합니다. `autoupdate` 옵션으로 비활성화할 수 있습니다.
```json title="opencode.json"
{
@@ -428,14 +426,14 @@ OpenCode는 시작될 때 자동으로 새로운 업데이트를 다운로드합
}
```
-업데이트를 원하지 않지만 새 버전을 알림받고 싶다면 `autoupdate`를 `"notify"`로 설정하십시오.
-Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경우에만 작동합니다.
+업데이트를 자동 적용하지 않고 새 버전 알림만 받고 싶다면 `autoupdate`를 `"notify"`로 설정하세요.
+이 옵션은 Homebrew 같은 패키지 매니저로 설치하지 않은 경우에만 동작합니다.
---
-## 포매터
+### Formatters
-`formatter` 옵션을 통해 코드 포매터를 구성할 수 있습니다.
+`formatter` 옵션으로 코드 formatter를 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -455,15 +453,15 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
}
```
-[포매터에 대해 더 알아보기](/docs/formatters).
+[formatter에 대해 더 알아보기](/docs/formatters).
---
-## 권한
+### Permissions
-기본적으로, OpenCode는 **명시적 승인 없이 모든 작업을 허용**합니다. `permission` 옵션을 사용하여 이를 변경할 수 있습니다.
+기본적으로 OpenCode는 **명시적 승인 없이 모든 작업을 허용**합니다. `permission` 옵션으로 이 동작을 바꿀 수 있습니다.
-예를 들어, `edit` 및 `bash` 도구가 사용자 승인을 요구하도록 설정하려면:
+예를 들어 `edit`, `bash` tool이 사용자 승인을 요구하게 하려면:
```json title="opencode.json"
{
@@ -475,32 +473,34 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
}
```
-[권한에 대해 더 알아보기](/docs/permissions).
+[permission에 대해 더 알아보기](/docs/permissions).
---
-### 압축
+### Compaction
-`compaction` 옵션을 통해 컨텍스트 압축 동작을 제어할 수 있습니다.
+`compaction` 옵션으로 context compaction 동작을 제어할 수 있습니다.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
- "prune": true
+ "prune": true,
+ "reserved": 10000
}
}
```
-- `auto` - 컨텍스트가 꽉 차면 자동으로 세션을 압축합니다 (기본값: `true`).
-- `prune` - 토큰을 절약하기 위해 오래된 도구 출력을 제거합니다 (기본값: `true`).
+- `auto` - context가 가득 찼을 때 세션을 자동 compact합니다(기본값: `true`).
+- `prune` - token 절약을 위해 오래된 tool 출력을 제거합니다(기본값: `true`).
+- `reserved` - compaction용 token buffer입니다. compaction 중 overflow가 나지 않도록 충분한 window를 남깁니다.
---
-### 파일 감시자
+### Watcher
-`watcher` 옵션을 통해 파일 감시자가 무시할 패턴을 설정할 수 있습니다.
+`watcher` 옵션으로 파일 watcher ignore 패턴을 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -511,13 +511,13 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
}
```
-패턴은 glob 구문을 따릅니다. 잡음이 많은 디렉토리를 제외하는 데 사용하십시오.
+패턴은 glob 문법을 따릅니다. 파일 감시에서 노이즈가 많은 디렉토리를 제외할 때 유용합니다.
---
-### MCP 서버
+### MCP servers
-`mcp` 옵션을 통해 사용하려는 MCP 서버를 구성할 수 있습니다.
+`mcp` 옵션으로 사용할 MCP server를 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -530,11 +530,11 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
---
-### 플러그인
+### Plugins
-[플러그인](/docs/plugins)은 사용자 정의 도구, 훅(hook), 통합으로 OpenCode를 확장합니다.
+[Plugins](/docs/plugins)는 custom tool, hook, integration으로 OpenCode를 확장합니다.
-`.opencode/plugins/` 또는 `~/.config/opencode/plugins/`에 플러그인 파일을 배치하십시오. `plugin` 옵션을 통해 npm에서 플러그인을 로드할 수 있습니다.
+plugin 파일은 `.opencode/plugins/` 또는 `~/.config/opencode/plugins/`에 두세요. `plugin` 옵션으로 npm plugin을 로드할 수도 있습니다.
```json title="opencode.json"
{
@@ -547,9 +547,9 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
---
-### 지침
+### Instructions
-`instructions` 옵션을 통해 모델에 대한 지침(Rules)을 구성할 수 있습니다.
+`instructions` 옵션으로 사용 중인 model에 제공할 지침 파일을 설정할 수 있습니다.
```json title="opencode.json"
{
@@ -558,13 +558,13 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
}
```
-지침 파일에 대한 경로와 glob 패턴의 배열을 사용합니다. [규칙에 대해 더 알아보기](/docs/rules).
+이 옵션은 지침 파일 경로 및 glob 패턴 배열을 받습니다. [rules에 대해 더 알아보기](/docs/rules).
---
-## 비활성화된 공급자
+### Disabled providers
-`disabled_providers` 옵션을 통해 자동으로 로드되는 공급자를 비활성화할 수 있습니다. 자격 증명이 유효하더라도 특정 공급자가 로드되는 것을 방지할 때 유용합니다.
+`disabled_providers` 옵션으로 자동 로드되는 provider를 비활성화할 수 있습니다. credential이 있어도 특정 provider를 로드하지 않게 하고 싶을 때 유용합니다.
```json title="opencode.json"
{
@@ -577,17 +577,17 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
`disabled_providers`는 `enabled_providers`보다 우선합니다.
:::
-`disabled_providers` 옵션은 공급자 ID의 배열을 허용합니다. 공급자가 비활성화되면:
+`disabled_providers`는 provider ID 배열을 받습니다. provider가 비활성화되면:
-- 환경 변수가 설정된 경우에도 로드되지 않습니다.
-- API 키가 `/connect` 명령을 통해 구성되는 경우에도 로드되지 않습니다.
-- 공급자의 모델은 모델 선택 목록에 표시되지 않습니다.
+- 환경 변수가 설정되어 있어도 로드되지 않습니다.
+- `/connect` 명령으로 API key를 설정해도 로드되지 않습니다.
+- 해당 provider의 model은 model 선택 목록에 표시되지 않습니다.
---
-### 활성화된 공급자
+### Enabled providers
-`enabled_providers` 옵션을 통해 허용할 공급자를 지정할 수 있습니다. 설정하면 지정된 공급자만 활성화되고 다른 모든 공급자는 무시됩니다.
+`enabled_providers` 옵션으로 provider allowlist를 지정할 수 있습니다. 이 값을 설정하면 지정한 provider만 활성화되고 나머지는 무시됩니다.
```json title="opencode.json"
{
@@ -596,19 +596,19 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경
}
```
-OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합니다.
+provider를 하나씩 비활성화하는 대신, OpenCode가 특정 provider만 사용하도록 제한하고 싶을 때 유용합니다.
:::note
`disabled_providers`는 `enabled_providers`보다 우선합니다.
:::
-공급자가 `enabled_providers`와 `disabled_providers` 둘 다에 나타나면, 하위 호환성을 위해 `disabled_providers`가 우선합니다.
+동일 provider가 `enabled_providers`와 `disabled_providers`에 모두 있으면 하위 호환성을 위해 `disabled_providers`가 우선합니다.
---
-### 실험적 기능
+### Experimental
-`experimental` 키는 활발히 개발 중인 옵션을 포함합니다.
+`experimental` key에는 현재 활발히 개발 중인 옵션이 포함됩니다.
```json title="opencode.json"
{
@@ -618,20 +618,20 @@ OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합
```
:::caution
-실험적 옵션은 안정적이지 않습니다. 예고 없이 변경되거나 제거될 수 있습니다.
+experimental 옵션은 안정적이지 않습니다. 예고 없이 변경되거나 제거될 수 있습니다.
:::
---
-## 변수
+## Variables
-구성 파일에서 환경 변수를 참조하고 파일 내용에 대한 변수 대체를 사용할 수 있습니다.
+config 파일에서 환경 변수와 파일 내용을 참조할 수 있도록 변수 치환을 사용할 수 있습니다.
---
-##### 환경 변수
+### Env vars
-`{env:VARIABLE_NAME}`을 사용하여 환경 변수를 대체합니다.
+`{env:VARIABLE_NAME}` 형식으로 환경 변수를 치환할 수 있습니다.
```json title="opencode.json"
{
@@ -648,13 +648,13 @@ OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합
}
```
-환경 변수가 설정되지 않으면 빈 문자열로 대체됩니다.
+환경 변수가 설정되지 않았으면 빈 문자열로 치환됩니다.
---
-## 파일
+### Files
-`{file:path/to/file}`를 사용하여 파일의 내용을 대체합니다.
+`{file:path/to/file}` 형식으로 파일 내용을 치환할 수 있습니다.
```json title="opencode.json"
{
@@ -670,13 +670,13 @@ OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합
}
```
-파일 경로는:
+파일 경로는 다음을 지원합니다.
-- 구성 파일 디렉토리에 상대적이거나
-- `/` 또는 `~`로 시작하는 절대 경로여야 합니다.
+- config 파일 디렉토리 기준 상대 경로
+- `/` 또는 `~`로 시작하는 절대 경로
-이것은 다음에 유용합니다:
+이 기능은 다음 상황에 유용합니다.
-- API 키와 같은 민감한 데이터를 별도의 파일에 유지할 때.
-- 구성을 어지럽히지 않고 큰 지침 파일을 포함할 때.
-- 여러 구성 파일에서 공통 구성 스니펫을 공유할 때.
+- API key 같은 민감 정보를 별도 파일로 분리
+- 큰 지침 파일을 config를 복잡하게 만들지 않고 포함
+- 여러 config 파일에서 공통 설정 스니펫 재사용
diff --git a/packages/web/src/content/docs/ko/custom-tools.mdx b/packages/web/src/content/docs/ko/custom-tools.mdx
index 5da050f49bd7..77310557faa9 100644
--- a/packages/web/src/content/docs/ko/custom-tools.mdx
+++ b/packages/web/src/content/docs/ko/custom-tools.mdx
@@ -1,30 +1,30 @@
---
-title: 사용자 정의 도구
-description: OpenCode에서 LLM이 호출할 수 있는 도구를 만듭니다.
+title: Custom Tools
+description: Create tools the LLM can call in OpenCode.
---
-사용자 정의 도구는 LLM이 대화 중에 호출 할 수있는 기능을 만듭니다. 그들은 `read`, `write` 및 `bash`와 같은 opencode의 [붙박이 도구](./tools)와 함께 작동합니다.
+custom tool은 대화 중 LLM이 호출할 수 있도록 사용자가 직접 만든 함수입니다. `read`, `write`, `bash` 같은 OpenCode의 [built-in tools](/docs/tools)와 함께 동작합니다.
---
## 도구 만들기
-도구는 **TypeScript** 또는 **JavaScript** 파일로 정의됩니다. 그러나 도구 정의는 ** 어떤 언어로 작성된 스크립트를 호출 할 수 있습니다 ** - TypeScript 또는 JavaScript는 도구 정의 자체에서만 사용됩니다.
+tool은 **TypeScript** 또는 **JavaScript** 파일로 정의합니다. 다만 tool 정의에서 호출하는 스크립트는 **어떤 언어든** 사용할 수 있습니다. 즉, TypeScript/JavaScript는 tool 정의 자체에만 필요합니다.
---
-## 위치
+### 위치
-그들은 정의 할 수 있습니다:
+tool은 다음 위치에 둘 수 있습니다.
-- 프로젝트의 `.opencode/tools/` 디렉토리에 배치하여 로컬.
-- 또는 전 세계적으로 `~/.config/opencode/tools/`에 배치하여.
+- 프로젝트의 `.opencode/tools/` 디렉토리(로컬)
+- `~/.config/opencode/tools/` 디렉토리(전역)
---
-## 구조
+### 구조
-도구를 만드는 가장 쉬운 방법은 `tool()` helper를 사용하여 유형 안전 및 검증을 제공합니다.
+tool을 가장 쉽게 만드는 방법은 타입 안정성과 validation을 제공하는 `tool()` helper를 사용하는 것입니다.
```ts title=".opencode/tools/database.ts" {1}
import { tool } from "@opencode-ai/plugin"
@@ -41,13 +41,13 @@ export default tool({
})
```
-**파일 이름**는 **tool name**가 됩니다. 위는 `database` 공구를 만듭니다.
+**파일 이름**이 **tool 이름**이 됩니다. 위 예시는 `database` tool을 생성합니다.
---
-### 파일당 여러 도구
+#### 파일 하나에 여러 tool 정의
-단일 파일에서 여러 도구를 수출할 수 있습니다. 각 수출은 ** 별도의 도구 ** 이름 ** `_`**:
+하나의 파일에서 여러 tool을 export할 수도 있습니다. 각 export는 **별도의 tool**이 되며 이름은 **`