Merge branch 'dev' into desktop-poilsh-styles-ui-ux

This commit is contained in:
Aaron Iker 2026-01-19 15:15:53 +01:00
commit e3b78fc03a
170 changed files with 12745 additions and 7027 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.23",
"version": "1.1.25",
"description": "",
"type": "module",
"exports": {

View file

@ -29,7 +29,7 @@ import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
const Loading = () => <div class="size-full" />
declare global {
interface Window {

View file

@ -1,6 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
@ -133,14 +134,14 @@ export function DialogSelectFile() {
})
return (
<Dialog title="Search">
<Dialog class="pt-3 pb-0 !max-h-[480px]">
<List
search={{ placeholder: "Search files and commands", autofocus: true }}
search={{ placeholder: "Search files and commands", autofocus: true, hideIcon: true, class: "pl-3 pr-2 !mb-0" }}
emptyMessage="No results found"
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
groupBy={(item) => (grouped() ? item.category : "")}
groupBy={(item) => item.category}
onMove={handleMove}
onSelect={handleSelect}
>
@ -148,7 +149,7 @@ export function DialogSelectFile() {
<Show
when={item.type === "command"}
fallback={
<div class="w-full flex items-center justify-between rounded-md">
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
@ -161,7 +162,7 @@ export function DialogSelectFile() {
</div>
}
>
<div class="w-full flex items-center justify-between gap-4">
<div class="w-full flex items-center justify-between gap-4 pl-1">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
@ -169,7 +170,7 @@ export function DialogSelectFile() {
</Show>
</div>
<Show when={item.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(item.keybind ?? "")}</span>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
</Show>

View file

@ -1,21 +1,24 @@
import { createMemo, createResource, Show } from "solid-js"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
// import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Decode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
@ -25,6 +28,7 @@ export function SessionHeader() {
// const server = useServer()
// const dialog = useDialog()
const sync = useSync()
const platform = usePlatform()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const project = createMemo(() => {
@ -44,6 +48,78 @@ export function SessionHeader() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
function shareSession() {
const session = currentSession()
if (!session || state.share) return
setState("share", true)
globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
function unshareSession() {
const session = currentSession()
if (!session || state.unshare) return
setState("unshare", true)
globalSDK.client.session
.unshare({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
function copyLink() {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch((error) => {
console.error("Failed to copy share link", error)
})
}
function viewShare() {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
@ -57,23 +133,14 @@ export function SessionHeader() {
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
>
<div class="flex items-center gap-2">
<Icon name="magnifying-glass" size="normal" class="icon-base" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate" style={{ "line-height": 1 }}>
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
<Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
Search {name()}
</span>
</div>
<Show when={hotkey()}>
{(keybind) => (
<span
class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] bg-surface-base text-12-medium text-text-weak"
style={{ "box-shadow": "var(--shadow-xxs-border)" }}
>
{keybind()}
</span>
)}
</Show>
<Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show>
</button>
</Portal>
)}
@ -167,40 +234,81 @@ export function SessionHeader() {
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
<div class="flex items-center">
<Popover
title="Publish on web"
description={
shareUrl()
? "This session is public on the web. It is accessible to anyone with the link."
: "Share session publicly on the web. It will be accessible to anyone with the link."
}
trigger={
<Tooltip class="shrink-0" value="Share session">
<Button
variant="secondary"
classList={{ "rounded-r-none": shareUrl() !== undefined }}
style={{ scale: 1 }}
>
Share
</Button>
</Tooltip>
}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share ? "Publishing..." : "Publish"}
</Button>
</div>
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
>
<div class="flex flex-col gap-2 w-72">
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare ? "Unpublishing..." : "Unpublish"}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
View
</Button>
</div>
</div>
</Show>
)
})}
</Popover>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
/>
</Tooltip>
</Show>
</div>
</Show>
</div>
</Portal>

View file

@ -81,13 +81,15 @@ export function Titlebar() {
>
<Show when={mac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton icon="menu" variant="ghost" class="size-8 rounded-md" onClick={layout.mobileSidebar.toggle} />
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton icon="menu" variant="ghost" class="size-8 rounded-md" onClick={layout.mobileSidebar.toggle} />
</div>
</Show>
<IconButton
icon="menu"
variant="ghost"
class="xl:hidden size-8 rounded-md"
onClick={layout.mobileSidebar.toggle}
/>
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0"}
placement="bottom"

View file

@ -1,5 +1,6 @@
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@ -104,7 +105,15 @@ export function formatKeybind(config: string): string {
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
const arrows: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
}
const displayKey =
arrows[kb.key.toLowerCase()] ??
(kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
parts.push(displayKey)
}
@ -114,6 +123,7 @@ export function formatKeybind(config: string): string {
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const dialog = useDialog()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
@ -157,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return
if (suspended() || dialog.active) return
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {

View file

@ -110,6 +110,7 @@ function createGlobalSync() {
})
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
@ -122,29 +123,33 @@ function createGlobalSync() {
if (!cache) throw new Error("Failed to create persisted cache")
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
children[directory] = createStore<State>({
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const,
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
limit: 5,
message: {},
part: {},
})
bootstrapInstance(directory)
const init = () => {
children[directory] = createStore<State>({
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const,
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
limit: 5,
message: {},
part: {},
})
bootstrapInstance(directory)
}
runWithOwner(owner, init)
}
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
@ -346,6 +351,23 @@ function createGlobalSync() {
bootstrapInstance(directory)
break
}
case "session.created": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
if (!event.properties.info.parentID) {
setStore("sessionTotal", store.sessionTotal + 1)
}
break
}
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (event.properties.info.time.archived) {
@ -357,6 +379,8 @@ function createGlobalSync() {
}),
)
}
if (event.properties.info.parentID) break
setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
if (result.found) {

View file

@ -36,6 +36,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
@ -197,6 +198,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
result.splice(toIndex, 0, item)
setStore("projects", key, result)
},
last() {
const key = origin()
if (!key) return
return store.lastProject[key]
},
touch(directory: string) {
const key = origin()
if (!key) return
setStore("lastProject", key, directory)
},
},
}
},

View file

@ -1,4 +1,3 @@
import { useGlobalSync } from "@/context/global-sync"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
@ -12,6 +11,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
export default function Home() {
const sync = useGlobalSync()
@ -24,6 +24,7 @@ export default function Home() {
function openProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}

File diff suppressed because it is too large Load diff

View file

@ -419,7 +419,6 @@ export default function Page() {
{
id: "session.new",
title: "New session",
description: "Create a new session",
category: "Session",
keybind: "mod+shift+s",
slash: "new",
@ -437,7 +436,7 @@ export default function Page() {
{
id: "terminal.toggle",
title: "Toggle terminal",
description: "Show or hide the terminal",
description: "",
category: "View",
keybind: "ctrl+`",
slash: "terminal",
@ -446,7 +445,7 @@ export default function Page() {
{
id: "review.toggle",
title: "Toggle review",
description: "Show or hide the review panel",
description: "",
category: "View",
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
@ -534,10 +533,6 @@ export default function Page() {
keybind: "shift+mod+t",
onSelect: () => {
local.model.variant.cycle()
showToast({
title: "Thinking effort changed",
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
})
},
},
{
@ -655,6 +650,72 @@ export default function Page() {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: "Share session",
description: "Share this session and copy the URL to clipboard",
category: "Session",
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: "Failed to copy URL to clipboard",
variant: "error",
}),
)
})
.then(() =>
showToast({
title: "Session shared",
description: "Share URL copied to clipboard!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to share session",
description: "An error occurred while sharing the session",
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: "Unshare session",
description: "Stop sharing this session",
category: "Session",
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: "Session unshared",
description: "Session unshared successfully!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to unshare session",
description: "An error occurred while unsharing the session",
variant: "error",
}),
)
},
},
]
: []),
])
const handleKeyDown = (event: KeyboardEvent) => {
@ -1092,7 +1153,7 @@ export default function Page() {
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
root: "pb-[calc(var(--prompt-height,8rem)+24px)]",
header: "px-4",
container: "px-4",
}}
@ -1237,7 +1298,7 @@ export default function Page() {
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -183,7 +183,12 @@ export async function POST(input: APIEvent) {
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
subscription: {
status: "subscribed",
coupon: couponID,
seats: 1,
plan: "200",
},
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
@ -408,7 +413,7 @@ export async function POST(input: APIEvent) {
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({ subscriptionID: null, subscriptionCouponID: null })
.set({ subscriptionID: null, subscription: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))

View file

@ -64,23 +64,20 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
newBuffer.set(value, buffer.length)
buffer = newBuffer
if (buffer.length < 4) return
// The first 4 bytes are the total length (big-endian).
const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false)
const messages = []
while (buffer.length >= 4) {
// first 4 bytes are the total length (big-endian)
const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false)
// If we don't have the full message yet, wait for more chunks.
if (buffer.length < totalLength) return
// wait for more chunks
if (buffer.length < totalLength) break
try {
// Decode exactly the sub-slice for this event.
const subView = buffer.subarray(0, totalLength)
const decoded = codec.decode(subView)
try {
const subView = buffer.subarray(0, totalLength)
const decoded = codec.decode(subView)
buffer = buffer.slice(totalLength)
// Slice the used bytes out of the buffer, removing this message.
buffer = buffer.slice(totalLength)
// Process message
/* Example of Bedrock data
/* Example of Bedrock data
```
{
bytes: 'eyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtb3B1cy00LTUtMjAyNTExMDEiLCJpZCI6Im1zZ19iZHJrXzAxMjVGdHRGb2lkNGlwWmZ4SzZMbktxeCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjo0LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjEsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoxMTk2MywiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6MSwiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6MH0sIm91dHB1dF90b2tlbnMiOjF9fX0=',
@ -112,22 +109,30 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
```
*/
/* Example of Anthropic data
/* Example of Anthropic data
```
event: message_delta
data: {"type":"message_start","message":{"model":"claude-opus-4-5-20251101","id":"msg_01ETvwVWSKULxzPdkQ1xAnk2","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":11543,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":11543,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}}
```
*/
if (decoded.headers[":message-type"]?.value !== "event") return
const data = decoder.decode(decoded.body, { stream: true })
if (decoded.headers[":message-type"]?.value === "event") {
const data = decoder.decode(decoded.body, { stream: true })
const parsedDataResult = JSON.parse(data)
delete parsedDataResult.p
const utf8 = atob(parsedDataResult.bytes)
return encoder.encode(["event: message_start", "\n", "data: " + utf8, "\n\n"].join(""))
} catch (e) {
console.log(e)
const parsedDataResult = JSON.parse(data)
delete parsedDataResult.p
const binary = atob(parsedDataResult.bytes)
const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0))
const bytes = decoder.decode(uint8)
const eventName = JSON.parse(bytes).type
messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join(""))
}
} catch (e) {
console.log("@@@EE@@@")
console.log(e)
break
}
}
return encoder.encode(messages.join(""))
}
},
streamSeparator: "\n\n",

View file

@ -0,0 +1 @@
ALTER TABLE `billing` ADD `subscription` json;

View file

@ -0,0 +1 @@
ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -372,6 +372,20 @@
"when": 1768343920467,
"tag": "0052_aromatic_agent_zero",
"breakpoints": true
},
{
"idx": 53,
"version": "5",
"when": 1768599366758,
"tag": "0053_gigantic_hardball",
"breakpoints": true
},
{
"idx": 54,
"version": "5",
"when": 1768603665356,
"tag": "0054_numerous_annihilus",
"breakpoints": true
}
]
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.23",
"version": "1.1.25",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -0,0 +1,112 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
const plan = "200"
const workspaceID = process.argv[2]
const seats = parseInt(process.argv[3])
console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`)
if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts <workspaceID> <seats>")
// Get workspace user
const users = await Database.use((tx) =>
tx
.select({
id: UserTable.id,
role: UserTable.role,
email: AuthTable.subject,
})
.from(UserTable)
.leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`)
if (users.length !== seats)
throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`)
const adminUser = users.find((user) => user.role === "admin")
if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`)
if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`)
// Get Billing
const billing = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`)
if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`)
// Look up the Stripe customer by email
const customerID =
billing.customerID ??
(await (() =>
Billing.stripe()
.customers.create({
email: adminUser.email,
metadata: {
workspaceID,
},
})
.then((customer) => customer.id))())
console.log(`Customer ID: ${customerID}`)
const couponID = "JAIr0Pe1"
const subscription = await Billing.stripe().subscriptions.create({
customer: customerID!,
items: [
{
price: `price_1SmfyI2StuRr0lbXovxJNeZn`,
discounts: [{ coupon: couponID }],
quantity: 2,
},
],
})
console.log(`Subscription ID: ${subscription.id}`)
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID: subscription.id,
subscription: { status: "subscribed", coupon: couponID, seats, plan },
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Create a row in subscription table
for (const user of users) {
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
}
//
// // Create a row in payments table
// await tx.insert(PaymentTable).values({
// workspaceID,
// id: Identifier.create("payment"),
// amount: centsToMicroCents(amountInCents),
// customerID,
// invoiceID,
// paymentID,
// enrichment: {
// type: "subscription",
// couponID,
// },
// })
})
console.log(`done`)

View file

@ -12,7 +12,7 @@ const email = process.argv[3]
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
if (!workspaceID || !email) {
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
console.error("Usage: bun foo.ts <workspaceID> <email>")
process.exit(1)
}
@ -50,7 +50,7 @@ const existingSubscription = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`)
.then((rows) => rows[0]),
)
if (existingSubscription) {
@ -128,10 +128,15 @@ await Database.transaction(async (tx) => {
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
subscription: {
status: "subscribed",
coupon: couponID,
seats: 1,
plan: "200",
},
})
.where(eq(BillingTable.workspaceID, workspaceID))

View file

@ -18,7 +18,7 @@ const fromBilling = await Database.use((tx) =>
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
subscriptionCouponID: BillingTable.subscriptionCouponID,
subscription: BillingTable.subscription,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
@ -119,7 +119,7 @@ await Database.transaction(async (tx) => {
.set({
customerID: fromPrevPayment.customerID,
subscriptionID: null,
subscriptionCouponID: null,
subscription: null,
paymentMethodID: fromPrevPaymentMethods.data[0].id,
paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null,
paymentMethodType: fromPrevPaymentMethods.data[0].type,
@ -131,7 +131,7 @@ await Database.transaction(async (tx) => {
.set({
customerID: fromBilling.customerID,
subscriptionID: fromBilling.subscriptionID,
subscriptionCouponID: fromBilling.subscriptionCouponID,
subscription: fromBilling.subscription,
paymentMethodID: fromBilling.paymentMethodID,
paymentMethodLast4: fromBilling.paymentMethodLast4,
paymentMethodType: fromBilling.paymentMethodType,

View file

@ -55,8 +55,9 @@ if (identifier.startsWith("wrk_")) {
),
)
// Get all payments for these workspaces
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
for (const user of users) {
await printWorkspace(user.workspaceID)
}
}
async function printWorkspace(workspaceID: string) {
@ -114,11 +115,11 @@ async function printWorkspace(workspaceID: string) {
balance: BillingTable.balance,
customerID: BillingTable.customerID,
reload: BillingTable.reload,
subscriptionID: BillingTable.subscriptionID,
subscription: {
id: BillingTable.subscriptionID,
couponID: BillingTable.subscriptionCouponID,
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
enrichment: BillingTable.subscription,
},
})
.from(BillingTable)
@ -128,8 +129,13 @@ async function printWorkspace(workspaceID: string) {
rows.map((row) => ({
...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`,
subscription: row.subscription.id
? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}`
subscription: row.subscriptionID
? [
`Black ${row.subscription.enrichment!.plan}`,
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
`(ref: ${row.subscriptionID})`,
].join(" ")
: row.subscription.booked
? `Waitlist ${row.subscription.plan} plan`
: undefined,

View file

@ -218,6 +218,7 @@ export namespace Billing {
customer: customer.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {

View file

@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
reloadError: varchar("reload_error", { length: 255 }),
timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"),
subscription: json("subscription").$type<{
status: "subscribed"
coupon?: string
seats: number
plan: "20" | "100" | "200"
}>(),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
timeSubscriptionBooked: utc("time_subscription_booked"),
},

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.23",
"version": "1.1.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
@ -26,6 +26,17 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
)
}
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
if (!(elt instanceof Element)) {
// Fall back to a safe element when a non-element is passed.
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
}
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
}) as typeof window.getComputedStyle
let update: Update | null = null
const createPlatform = (password: Accessor<string | null>): Platform => ({
@ -345,8 +356,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.23",
"version": "1.1.25",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.23"
version = "1.1.25"
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.23/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/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.23/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/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.23/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/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.23/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.23",
"version": "1.1.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.23",
"version": "1.1.25",
"name": "opencode",
"type": "module",
"license": "MIT",
@ -70,7 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@gitlab/gitlab-ai-provider": "3.1.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@ -82,8 +82,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.73",
"@opentui/solid": "0.1.73",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View file

@ -90,6 +90,11 @@ const targets = singleFlag
return baselineFlag
}
// also skip abi-specific builds for the same reason
if (item.abi !== undefined) {
return false
}
return true
})
: allTargets

View file

@ -20,7 +20,7 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig, ACPSessionState } from "./types"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { Agent as AgentModule } from "../agent/agent"
import { Installation } from "@/installation"
@ -29,7 +29,7 @@ import { Config } from "@/config/config"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
export namespace ACP {
@ -47,304 +47,354 @@ export namespace ACP {
private connection: AgentSideConnection
private config: ACPConfig
private sdk: OpencodeClient
private sessionManager
private sessionManager: ACPSessionManager
private eventAbort = new AbortController()
private eventStarted = false
private permissionQueues = new Map<string, Promise<void>>()
private permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
constructor(connection: AgentSideConnection, config: ACPConfig) {
this.connection = connection
this.config = config
this.sdk = config.sdk
this.sessionManager = new ACPSessionManager(this.sdk)
this.startEventSubscription()
}
private setupEventSubscriptions(session: ACPSessionState) {
const sessionId = session.id
const directory = session.cwd
private startEventSubscription() {
if (this.eventStarted) return
this.eventStarted = true
this.runEventSubscription().catch((error) => {
if (this.eventAbort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
private async runEventSubscription() {
while (true) {
if (this.eventAbort.signal.aborted) return
const events = await this.sdk.global.event({
signal: this.eventAbort.signal,
})
for await (const event of events.stream) {
switch (event.type) {
case "permission.asked":
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options,
if (this.eventAbort.signal.aborted) return
const payload = (event as any)?.payload
if (!payload) continue
await this.handleEvent(payload as Event).catch((error) => {
log.error("failed to handle event", { error, type: payload.type })
})
}
}
}
private async handleEvent(event: Event) {
switch (event.type) {
case "permission.asked": {
const permission = event.properties
const session = this.sessionManager.tryGet(permission.sessionID)
if (!session) return
const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
const next = prev
.then(async () => {
const directory = session.cwd
const res = await this.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: this.permissionOptions,
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.config.sdk.permission.reply({
await this.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
}
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
return undefined
})
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
sessionId: sessionId,
path: filepath,
content: newContent,
})
}
}
await this.config.sdk.permission.reply({
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
reply: "reject",
directory,
})
} catch (err) {
log.error("unexpected error when handling permission", { error: err })
} finally {
break
return
}
case "message.part.updated":
log.info("message part updated", { event: event.properties })
try {
const props = event.properties
const { part } = props
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const message = await this.config.sdk.session
.message(
{
sessionID: part.sessionID,
messageID: part.messageID,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
sessionId: session.id,
path: filepath,
content: newContent,
})
}
}
if (!message || message.info.role !== "assistant") return
await this.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
directory,
})
})
.catch((error) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.permissionQueues.get(permission.sessionID) === next) {
this.permissionQueues.delete(permission.sessionID)
}
})
this.permissionQueues.set(permission.sessionID, next)
return
}
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((err) => {
log.error("failed to send tool pending to ACP", { error: err })
})
break
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
break
case "completed":
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
case "message.part.updated": {
log.info("message part updated", { event: event.properties })
const props = event.properties
const part = props.part
const session = this.sessionManager.tryGet(part.sessionID)
if (!session) return
const sessionId = session.id
const directory = session.cwd
const message = await this.sdk.session
.message(
{
sessionID: part.sessionID,
messageID: part.messageID,
directory,
},
{ 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
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((error) => {
log.error("failed to send tool pending to ACP", { error })
})
return
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((error) => {
log.error("failed to send tool in_progress to ACP", { error })
})
return
case "completed": {
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
},
})
.catch((error) => {
log.error("failed to send session update for todo", { error })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
}
}
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((error) => {
log.error("failed to send tool completed to ACP", { error })
})
return
}
case "error":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: part.state.output,
text: part.state.error,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
},
})
.catch((err) => {
log.error("failed to send session update for todo", { error: err })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
}
}
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
break
case "error":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
},
},
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
break
}
} else if (part.type === "text") {
const delta = props.delta
if (delta && part.synthetic !== true) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send text to ACP", { error: err })
})
}
} else 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,
},
},
})
.catch((err) => {
log.error("failed to send reasoning to ACP", { error: err })
})
}
}
} finally {
break
}
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((error) => {
log.error("failed to send tool error to ACP", { error })
})
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,
},
},
})
.catch((error) => {
log.error("failed to send text 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,
},
},
})
.catch((error) => {
log.error("failed to send reasoning to ACP", { error })
})
}
}
return
}
})
}
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
@ -409,8 +459,6 @@ export namespace ACP {
sessionId,
})
this.setupEventSubscriptions(state)
return {
sessionId,
models: load.models,
@ -436,18 +484,16 @@ export namespace ACP {
const model = await defaultModel(this.config, directory)
// Store ACP session state
const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
const mode = await this.loadSessionMode({
const result = await this.loadSessionMode({
cwd: directory,
mcpServers: params.mcpServers,
sessionId,
})
this.setupEventSubscriptions(state)
// Replay session history
const messages = await this.sdk.session
.messages(
@ -463,12 +509,20 @@ export namespace ACP {
return undefined
})
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
if (lastUser?.role === "user") {
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) {
result.modes.currentModeId = lastUser.agent
}
}
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
}
return mode
return result
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: this.config.defaultModel?.providerID ?? "unknown",
@ -633,7 +687,7 @@ export namespace ACP {
break
}
} else if (part.type === "text") {
if (part.text) {
if (part.text && !part.ignored) {
await this.connection
.sessionUpdate({
sessionId,
@ -649,6 +703,83 @@ export namespace ACP {
log.error("failed to send text to ACP", { error: err })
})
}
} else if (part.type === "file") {
// Replay file attachments as appropriate ACP content blocks.
// OpenCode stores files internally as { type: "file", url, filename, mime }.
// We convert these back to ACP blocks based on the URL scheme and MIME type:
// - file:// URLs → resource_link
// - data: URLs with image/* → image block
// - data: URLs with text/* or application/json → resource with text
// - data: URLs with other types → resource with blob
const url = part.url
const filename = part.filename ?? "file"
const mime = part.mime || "application/octet-stream"
const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
if (url.startsWith("file://")) {
// Local file reference - send as resource_link
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: messageChunk,
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
},
})
.catch((err) => {
log.error("failed to send resource_link to ACP", { error: err })
})
} else if (url.startsWith("data:")) {
// Embedded content - parse data URL and send as appropriate block type
const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
const dataMime = base64Match?.[1]
const base64Data = base64Match?.[2] ?? ""
const effectiveMime = dataMime || mime
if (effectiveMime.startsWith("image/")) {
// Image - send as image block
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: messageChunk,
content: {
type: "image",
mimeType: effectiveMime,
data: base64Data,
uri: `file://${filename}`,
},
},
})
.catch((err) => {
log.error("failed to send image to ACP", { error: err })
})
} else {
// Non-image: text types get decoded, binary types stay as blob
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
const resource = isText
? {
uri: `file://${filename}`,
mimeType: effectiveMime,
text: Buffer.from(base64Data, "base64").toString("utf-8"),
}
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: messageChunk,
content: { type: "resource", resource },
},
})
.catch((err) => {
log.error("failed to send resource to ACP", { error: err })
})
}
}
// URLs that don't match file:// or data: are skipped (unsupported)
} else if (part.type === "reasoning") {
if (part.text) {
await this.connection
@ -847,39 +978,57 @@ export namespace ACP {
text: part.text,
})
break
case "image":
case "image": {
const parsed = parseUri(part.uri ?? "")
const filename = parsed.type === "file" ? parsed.filename : "image"
if (part.data) {
parts.push({
type: "file",
url: `data:${part.mimeType};base64,${part.data}`,
filename: "image",
filename,
mime: part.mimeType,
})
} else if (part.uri && part.uri.startsWith("http:")) {
parts.push({
type: "file",
url: part.uri,
filename: "image",
filename,
mime: part.mimeType,
})
}
break
}
case "resource_link":
const parsed = parseUri(part.uri)
// Use the name from resource_link if available
if (part.name && parsed.type === "file") {
parsed.filename = part.name
}
parts.push(parsed)
break
case "resource":
case "resource": {
const resource = part.resource
if ("text" in resource) {
if ("text" in resource && resource.text) {
parts.push({
type: "text",
text: resource.text,
})
} else if ("blob" in resource && resource.blob && resource.mimeType) {
// Binary resource (PDFs, etc.): store as file part with data URL
const parsed = parseUri(resource.uri ?? "")
const filename = parsed.type === "file" ? parsed.filename : "file"
parts.push({
type: "file",
url: `data:${resource.mimeType};base64,${resource.blob}`,
filename,
mime: resource.mimeType,
})
}
break
}
default:
break

View file

@ -13,6 +13,10 @@ export class ACPSessionManager {
this.sdk = sdk
}
tryGet(sessionId: string): ACPSessionState | undefined {
return this.sessions.get(sessionId)
}
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
const session = await this.sdk.session
.create(

View file

@ -1,10 +1,12 @@
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@ -276,10 +278,12 @@ export namespace Agent {
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = SystemPrompt.header(defaultModel.providerID)
system.push(PROMPT_GENERATE)
const existing = await list()
const result = await generateObject({
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
@ -305,7 +309,24 @@ export namespace Agent {
whenToUse: z.string(),
systemPrompt: z.string(),
}),
})
} satisfies Parameters<typeof generateObject>[0]
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
return result.object
}
}

View file

@ -2,6 +2,7 @@ import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
@ -71,7 +72,10 @@ export namespace BunProc {
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
return result
})
if (parsed.dependencies[pkg] === version) return mod
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
const modExists = await Filesystem.exists(mod)
if (dependencies[pkg] === version && modExists) return mod
const proxied = !!(
process.env.HTTP_PROXY ||

View file

@ -70,8 +70,8 @@ export const AgentCommand = cmd({
})
async function getAvailableTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
return ToolRegistry.tools(providerID, agent)
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

View file

@ -6,7 +6,6 @@ import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthCallback } from "../../mcp/oauth-callback"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config/config"
import { Instance } from "../../project/instance"
@ -683,10 +682,6 @@ export const McpDebugCommand = cmd({
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
// Start callback server
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
@ -694,7 +689,6 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},

View file

@ -200,11 +200,6 @@ function App() {
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
// @ts-expect-error writeOut is not in type definitions
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@ -293,6 +288,10 @@ function App() {
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
slash: {
name: "sessions",
aliases: ["resume", "continue"],
},
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
@ -303,6 +302,10 @@ function App() {
value: "session.new",
keybind: "session_new",
category: "Session",
slash: {
name: "new",
aliases: ["clear"],
},
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
@ -320,26 +323,29 @@ function App() {
keybind: "model_list",
suggested: true,
category: "Agent",
slash: {
name: "models",
},
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Model cycle",
disabled: true,
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(1)
},
},
{
title: "Model cycle reverse",
disabled: true,
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(-1)
},
@ -349,6 +355,7 @@ function App() {
value: "model.cycle_favorite",
keybind: "model_cycle_favorite",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(1)
},
@ -358,6 +365,7 @@ function App() {
value: "model.cycle_favorite_reverse",
keybind: "model_cycle_favorite_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(-1)
},
@ -367,6 +375,9 @@ function App() {
value: "agent.list",
keybind: "agent_list",
category: "Agent",
slash: {
name: "agents",
},
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
@ -375,6 +386,9 @@ function App() {
title: "Toggle MCPs",
value: "mcp.list",
category: "Agent",
slash: {
name: "mcps",
},
onSelect: () => {
dialog.replace(() => <DialogMcp />)
},
@ -384,7 +398,7 @@ function App() {
value: "agent.cycle",
keybind: "agent_cycle",
category: "Agent",
disabled: true,
hidden: true,
onSelect: () => {
local.agent.move(1)
},
@ -394,6 +408,7 @@ function App() {
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.variant.cycle()
},
@ -403,7 +418,7 @@ function App() {
value: "agent.cycle.reverse",
keybind: "agent_cycle_reverse",
category: "Agent",
disabled: true,
hidden: true,
onSelect: () => {
local.agent.move(-1)
},
@ -412,6 +427,9 @@ function App() {
title: "Connect provider",
value: "provider.connect",
suggested: !connected(),
slash: {
name: "connect",
},
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
@ -421,6 +439,9 @@ function App() {
title: "View status",
keybind: "status_view",
value: "opencode.status",
slash: {
name: "status",
},
onSelect: () => {
dialog.replace(() => <DialogStatus />)
},
@ -430,6 +451,9 @@ function App() {
title: "Switch theme",
value: "theme.switch",
keybind: "theme_list",
slash: {
name: "themes",
},
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
@ -447,6 +471,9 @@ function App() {
{
title: "Help",
value: "help.show",
slash: {
name: "help",
},
onSelect: () => {
dialog.replace(() => <DialogHelp />)
},
@ -473,6 +500,10 @@ function App() {
{
title: "Exit the app",
value: "app.exit",
slash: {
name: "exit",
aliases: ["quit", "q"],
},
onSelect: () => exit(),
category: "System",
},
@ -513,6 +544,7 @@ function App() {
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
hidden: true,
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()
@ -627,11 +659,6 @@ function App() {
}
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)

View file

@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type CommandOption = DialogSelectOption & {
export type Slash = {
name: string
aliases?: string[]
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: keyof KeybindsConfig
suggested?: boolean
slash?: Slash
hidden?: boolean
enabled?: boolean
}
function init() {
@ -26,27 +34,35 @@ function init() {
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const options = createMemo(() => {
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x())
const suggested = all.filter((x) => x.suggested)
return [
...suggested.map((x) => ({
...x,
category: "Suggested",
value: "suggested." + x.value,
})),
...all,
].map((x) => ({
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
for (const option of options()) {
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
@ -56,20 +72,33 @@ function init() {
})
const result = {
trigger(name: string, source?: "prompt") {
for (const option of options()) {
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
option.onSelect?.(dialog, source)
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
}
},
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
show() {
dialog.replace(() => <DialogCommand options={options()} />)
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
@ -78,9 +107,6 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
get options() {
return options()
},
}
return result
}
@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) {
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
dialog.replace(() => <DialogCommand options={value.options} />)
value.show()
return
}
})
@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[] }) {
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
let ref: DialogSelectRef<string>
return (
<DialogSelect
ref={(r) => (ref = r)}
title="Commands"
options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))}
/>
)
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
}

View file

@ -332,16 +332,15 @@ export function Autocomplete(props: {
)
})
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = []
const s = session()
for (const command of sync.data.command) {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
results.push({
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
description: command.description,
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
description: serverCommand.description,
onSelect: () => {
const newText = "/" + command.name + " "
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
@ -349,138 +348,9 @@ export function Autocomplete(props: {
},
})
}
if (s) {
results.push(
{
display: "/undo",
description: "undo the last message",
onSelect: () => {
command.trigger("session.undo")
},
},
{
display: "/redo",
description: "redo the last message",
onSelect: () => command.trigger("session.redo"),
},
{
display: "/compact",
aliases: ["/summarize"],
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/unshare",
disabled: !s.share,
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
{
display: "/rename",
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/copy",
description: "copy session transcript to clipboard",
onSelect: () => command.trigger("session.copy"),
},
{
display: "/export",
description: "export session transcript to file",
onSelect: () => command.trigger("session.export"),
},
{
display: "/timeline",
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
{
display: "/fork",
description: "fork from message",
onSelect: () => command.trigger("session.fork"),
},
{
display: "/thinking",
description: "toggle thinking visibility",
onSelect: () => command.trigger("session.toggle.thinking"),
},
)
if (sync.data.config.share !== "disabled") {
results.push({
display: "/share",
disabled: !!s.share?.url,
description: "share a session",
onSelect: () => command.trigger("session.share"),
})
}
}
results.push(
{
display: "/new",
aliases: ["/clear"],
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
{
display: "/models",
description: "list models",
onSelect: () => command.trigger("model.list"),
},
{
display: "/agents",
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/session",
aliases: ["/resume", "/continue"],
description: "list sessions",
onSelect: () => command.trigger("session.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/mcp",
description: "toggle MCPs",
onSelect: () => command.trigger("mcp.list"),
},
{
display: "/theme",
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{
display: "/connect",
description: "connect to a provider",
onSelect: () => command.trigger("provider.connect"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
{
display: "/commands",
description: "show all commands",
onSelect: () => command.show(),
},
{
display: "/exit",
aliases: ["/quit", "/q"],
description: "exit the app",
onSelect: () => command.trigger("app.exit"),
},
)
results.sort((a, b) => a.display.localeCompare(b.display))
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
@ -494,9 +364,8 @@ export function Autocomplete(props: {
const agentsValue = agents()
const commandsValue = commands()
const mixed: AutocompleteOption[] = (
const mixed: AutocompleteOption[] =
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
).filter((x) => x.disabled !== true)
const currentFilter = filter()

View file

@ -145,9 +145,9 @@ export function Prompt(props: PromptProps) {
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
}
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
}
})
@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) {
title: "Clear prompt",
value: "prompt.clear",
category: "Prompt",
disabled: true,
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) {
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
if (!input.focused) return
submit()
@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) {
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
@ -197,8 +197,9 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status().type === "idle",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.visible) return
if (!input.focused) return
@ -229,7 +230,10 @@ export function Prompt(props: PromptProps) {
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
onSelect: async (dialog, trigger) => {
slash: {
name: "editor",
},
onSelect: async (dialog) => {
dialog.clear()
// replace summarized text parts with the actual text
@ -242,7 +246,7 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = trigger === "prompt" ? "" : text
const value = text
const content = await Editor.open({ value, renderer })
if (!content) return
@ -432,7 +436,7 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
disabled: !store.prompt.input,
enabled: !!store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
@ -450,7 +454,7 @@ export function Prompt(props: PromptProps) {
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
disabled: stash.list().length === 0,
enabled: stash.list().length > 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
@ -466,7 +470,7 @@ export function Prompt(props: PromptProps) {
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
disabled: stash.list().length === 0,
enabled: stash.list().length > 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
@ -1065,9 +1069,11 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
</Show>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>

View file

@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
const state = {
pending: false,
}
function save() {
if (!modelStore.ready) {
state.pending = true
return
}
state.pending = false
Bun.write(
file,
JSON.stringify({
@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.catch(() => {})
.finally(() => {
setModelStore("ready", true)
if (state.pending) save()
})
const args = useArgs()

View file

@ -16,6 +16,8 @@ export const TuiEvent = {
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",

View file

@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
import type { ListTool } from "@/tool/ls"
import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
@ -295,37 +295,39 @@ export function Session() {
const command = useCommandDialog()
command.register(() => [
...(sync.data.config.share !== "disabled"
? [
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share" as const,
disabled: !!session()?.share?.url,
category: "Session",
onSelect: async (dialog: any) => {
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
]
: []),
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share",
category: "Session",
enabled: sync.data.config.share !== "disabled" && !session()?.share?.url,
slash: {
name: "share",
},
onSelect: async (dialog) => {
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
slash: {
name: "rename",
},
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
@ -335,6 +337,9 @@ export function Session() {
value: "session.timeline",
keybind: "session_timeline",
category: "Session",
slash: {
name: "timeline",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogTimeline
@ -355,6 +360,9 @@ export function Session() {
value: "session.fork",
keybind: "session_fork",
category: "Session",
slash: {
name: "fork",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogForkFromTimeline
@ -374,6 +382,10 @@ export function Session() {
value: "session.compact",
keybind: "session_compact",
category: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
onSelect: (dialog) => {
const selectedModel = local.model.current()
if (!selectedModel) {
@ -396,8 +408,11 @@ export function Session() {
title: "Unshare session",
value: "session.unshare",
keybind: "session_unshare",
disabled: !session()?.share?.url,
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
onSelect: async (dialog) => {
await sdk.client.session
.unshare({
@ -413,6 +428,9 @@ export function Session() {
value: "session.undo",
keybind: "messages_undo",
category: "Session",
slash: {
name: "undo",
},
onSelect: async (dialog) => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
@ -447,8 +465,11 @@ export function Session() {
title: "Redo",
value: "session.redo",
keybind: "messages_redo",
disabled: !session()?.revert?.messageID,
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
onSelect: (dialog) => {
dialog.clear()
const messageID = session()?.revert?.messageID
@ -495,6 +516,10 @@ export function Session() {
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
category: "Session",
slash: {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
onSelect: (dialog) => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
@ -504,6 +529,10 @@ export function Session() {
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
category: "Session",
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
onSelect: (dialog) => {
setShowThinking((prev) => !prev)
dialog.clear()
@ -513,6 +542,9 @@ export function Session() {
title: "Toggle diff wrapping",
value: "session.toggle.diffwrap",
category: "Session",
slash: {
name: "diffwrap",
},
onSelect: (dialog) => {
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
dialog.clear()
@ -552,7 +584,7 @@ export function Session() {
value: "session.page.up",
keybind: "messages_page_up",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
@ -563,18 +595,40 @@ export function Session() {
value: "session.page.down",
keybind: "messages_page_down",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
},
{
title: "Line up",
value: "session.line.up",
keybind: "messages_line_up",
category: "Session",
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(-1)
dialog.clear()
},
},
{
title: "Line down",
value: "session.line.down",
keybind: "messages_line_down",
category: "Session",
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(1)
dialog.clear()
},
},
{
title: "Half page up",
value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
@ -585,7 +639,7 @@ export function Session() {
value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
@ -596,7 +650,7 @@ export function Session() {
value: "session.first",
keybind: "messages_first",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollTo(0)
dialog.clear()
@ -607,7 +661,7 @@ export function Session() {
value: "session.last",
keybind: "messages_last",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
@ -618,6 +672,7 @@ export function Session() {
value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session",
hidden: true,
onSelect: () => {
const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return
@ -649,7 +704,7 @@ export function Session() {
value: "session.message.next",
keybind: "messages_next",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => scrollToMessage("next", dialog),
},
{
@ -657,7 +712,7 @@ export function Session() {
value: "session.message.previous",
keybind: "messages_previous",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => scrollToMessage("prev", dialog),
},
{
@ -697,11 +752,6 @@ export function Session() {
return
}
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
Clipboard.copy(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
@ -711,8 +761,10 @@ export function Session() {
{
title: "Copy session transcript",
value: "session.copy",
keybind: "session_copy",
category: "Session",
slash: {
name: "copy",
},
onSelect: async (dialog) => {
try {
const sessionData = session()
@ -740,6 +792,9 @@ export function Session() {
value: "session.export",
keybind: "session_export",
category: "Session",
slash: {
name: "export",
},
onSelect: async (dialog) => {
try {
const sessionData = session()
@ -798,7 +853,7 @@ export function Session() {
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
moveChild(1)
dialog.clear()
@ -809,7 +864,7 @@ export function Session() {
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
moveChild(-1)
dialog.clear()
@ -820,7 +875,7 @@ export function Session() {
value: "session.parent",
keybind: "session_parent",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
const parentID = session()?.parentID
if (parentID) {
@ -1390,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "task"}>
<Task {...toolprops} />
</Match>
<Match when={props.part.tool === "patch"}>
<Patch {...toolprops} />
<Match when={props.part.tool === "apply_patch"}>
<ApplyPatch {...toolprops} />
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
@ -1840,20 +1895,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
)
}
function Patch(props: ToolProps<typeof PatchTool>) {
const { theme } = useTheme()
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})
function Diff(p: { diff: string; filePath: string }) {
return (
<box paddingLeft={1}>
<diff
diff={p.diff}
view={view()}
filetype={filetype(p.filePath)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
)
}
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
return "← Patched " + file.relativePath
}
return (
<Switch>
<Match when={props.output !== undefined}>
<BlockTool title="# Patch" part={props.part}>
<box>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
</BlockTool>
<Match when={files().length > 0}>
<For each={files()}>
{(file) => (
<BlockTool title={title(file)} part={props.part}>
<Show
when={file.type !== "delete"}
fallback={
<text fg={theme.diffRemoved}>
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
</text>
}
>
<Diff diff={file.diff} filePath={file.filePath} />
</Show>
</BlockTool>
)}
</For>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
Patch
<InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
apply_patch
</InlineTool>
</Match>
</Switch>

View file

@ -280,6 +280,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
reply: "reject",
requestID: props.request.id,
})
return
}
sdk.client.permission.reply({
reply: "once",
@ -456,6 +457,11 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]}

View file

@ -38,7 +38,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
onSelect?: (ctx: DialogContext) => void
}
export type DialogSelectRef<T> = {
@ -157,26 +157,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const keybind = useKeybind()
useKeyboard((evt) => {
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
evt.preventDefault()
evt.stopPropagation()
move(-1)
}
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
evt.preventDefault()
evt.stopPropagation()
move(1)
}
if (evt.name === "pageup") {
evt.preventDefault()
evt.stopPropagation()
move(-10)
}
if (evt.name === "pagedown") {
evt.preventDefault()
evt.stopPropagation()
move(10)
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
if (evt.name === "return") {
const option = selected()
if (option) {

View file

@ -141,11 +141,6 @@ export function DialogProvider(props: ParentProps) {
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)

View file

@ -5,6 +5,21 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
// tmux and screen require DCS passthrough wrapping
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
data: string
@ -110,9 +125,25 @@ export namespace Clipboard {
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// need to escape backticks because powershell uses them as escape code
const escaped = text.replace(/"/g, '""').replace(/`/g, "``")
await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
@ -123,6 +154,7 @@ export namespace Clipboard {
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
}
}

View file

@ -133,6 +133,8 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation.
bun: "bun remove -g opencode-ai",
yarn: "yarn global remove opencode-ai",
brew: "brew uninstall opencode",
choco: "choco uninstall opencode",
scoop: "scoop uninstall opencode",
}
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
}
@ -182,16 +184,27 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
bun: ["bun", "remove", "-g", "opencode-ai"],
yarn: ["yarn", "global", "remove", "opencode-ai"],
brew: ["brew", "uninstall", "opencode"],
choco: ["choco", "uninstall", "opencode"],
scoop: ["scoop", "uninstall", "opencode"],
}
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await $`${cmd}`.quiet().nothrow()
const result =
method === "choco"
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
: await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed`, 1)
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
errors.push(`Package manager: exit code ${result.exitCode}`)
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
if (
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
}
} else {
spinner.stop("Package removed")
}

View file

@ -60,7 +60,11 @@ export const WebCommand = cmd({
}
if (opts.mdns) {
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`opencode.local:${server.port}`,
)
}
// Open localhost in browser

View file

@ -13,6 +13,7 @@ Based on the input provided, determine which type of review to perform:
1. **No arguments (default)**: Review all uncommitted changes
- Run: `git diff` for unstaged changes
- Run: `git diff --cached` for staged changes
- Run: `git status --short` to identify untracked (net new) files
2. **Commit hash** (40-char SHA or short hash): Review that specific commit
- Run: `git show $ARGUMENTS`
@ -33,6 +34,7 @@ Use best judgement when processing input.
**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa.
- Use the diff to identify which files changed
- Use `git status --short` to identify untracked files, then read their full contents
- Read the full file to understand existing patterns, control flow, and error handling
- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)

View file

@ -20,7 +20,6 @@ import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs"
import { Bus } from "@/bus"
import { Session } from "@/session"
export namespace Config {
const log = Log.create({ service: "config" })
@ -233,10 +232,11 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
@ -272,10 +272,11 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
@ -310,10 +311,11 @@ export namespace Config {
dot: true,
cwd: dir,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
@ -433,10 +435,6 @@ export namespace Config {
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
@ -653,8 +651,14 @@ export namespace Config {
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pagedown,ctrl+alt+f")
.describe("Scroll messages down by one page"),
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
@ -942,7 +946,7 @@ export namespace Config {
})
.catchall(Agent)
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agent"),
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z
.record(z.string(), Provider)
.optional()
@ -1117,6 +1121,7 @@ export namespace Config {
}
async function load(text: string, configFilepath: string) {
const original = text
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
@ -1186,7 +1191,9 @@ export namespace Config {
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(configFilepath, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin) {

View file

@ -158,7 +158,7 @@ export namespace Installation {
cmd = $`echo Y | choco upgrade opencode --version=${target}`
break
case "scoop":
cmd = $`scoop install extras/opencode@${target}`
cmd = $`scoop install opencode@${target}`
break
default:
throw new Error(`Unknown method: ${method}`)
@ -226,7 +226,7 @@ export namespace Installation {
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {

View file

@ -1157,10 +1157,24 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true })
const releaseURL =
"https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz"
const archivePath = path.join(distPath, "release.tar.gz")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow()
await fs.rm(archivePath, { force: true })
const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
if (curlResult.exitCode !== 0) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
return
}
log.info("Extracting JDTLS archive")
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
if (tarResult.exitCode !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
return
}
await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
}
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)

View file

@ -308,8 +308,6 @@ export namespace MCP {
let authProvider: McpOAuthProvider | undefined
if (!oauthDisabled) {
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
authProvider = new McpOAuthProvider(
key,
mcp.url,
@ -317,7 +315,6 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
@ -347,7 +344,6 @@ export namespace MCP {
let lastError: Error | undefined
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
for (const { name, transport } of transports) {
try {
const client = new Client({
@ -574,8 +570,7 @@ export namespace MCP {
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
// Only include tools from connected MCPs (skip disabled ones)
const clientStatus = s.status[clientName]?.status
if (clientStatus !== "connected") {
if (s.status[clientName]?.status !== "connected") {
continue
}
@ -725,10 +720,8 @@ export namespace MCP {
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
}
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
// Start the callback server
await McpOAuthCallback.ensureRunning()
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
// The SDK will call provider.state() to read this value
@ -738,6 +731,8 @@ export namespace MCP {
await McpAuth.updateOAuthState(mcpName, oauthState)
// Create a new auth provider for this flow
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
let capturedUrl: URL | undefined
const authProvider = new McpOAuthProvider(
mcpName,
@ -746,7 +741,6 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
@ -775,7 +769,6 @@ export namespace MCP {
pendingOAuthTransports.set(mcpName, transport)
return { authorizationUrl: capturedUrl.toString() }
}
throw error
}
}
@ -785,9 +778,9 @@ export namespace MCP {
* Opens the browser and waits for callback.
*/
export async function authenticate(mcpName: string): Promise<Status> {
const result = await startAuth(mcpName)
const { authorizationUrl } = await startAuth(mcpName)
if (!result.authorizationUrl) {
if (!authorizationUrl) {
// Already authenticated
const s = await state()
return s.status[mcpName] ?? { status: "connected" }
@ -801,9 +794,9 @@ export namespace MCP {
// The SDK has already added the state parameter to the authorization URL
// We just need to open the browser
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState })
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
try {
const subprocess = await open(result.authorizationUrl)
const subprocess = await open(authorizationUrl)
// The open package spawns a detached process and returns immediately.
// We need to listen for errors which fire asynchronously:
// - "error" event: command not found (ENOENT)
@ -826,7 +819,7 @@ export namespace MCP {
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
// Emit event so CLI can display the URL for manual opening
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl })
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
}
// Wait for callback using the OAuth state parameter

View file

@ -1,12 +1,8 @@
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" })
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
@ -60,33 +56,21 @@ export namespace McpOAuthCallback {
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
export async function ensureRunning(): Promise<void> {
if (server) return
const running = await isPortInUse(port)
const running = await isPortInUse()
if (running) {
log.info("oauth callback server already running on another instance", { port })
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
return
}
currentPort = port
currentPath = path
server = Bun.serve({
port: currentPort,
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== currentPath) {
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
@ -149,7 +133,7 @@ export namespace McpOAuthCallback {
},
})
log.info("oauth callback server started", { port: currentPort, path: currentPath })
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@ -174,11 +158,11 @@ export namespace McpOAuthCallback {
}
}
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port,
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()

View file

@ -17,7 +17,6 @@ export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
redirectUri?: string
}
export interface McpOAuthCallbacks {
@ -33,10 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
) {}
get redirectUrl(): string {
// Use configured redirectUri if provided, otherwise use OpenCode defaults
if (this.config.redirectUri) {
return this.config.redirectUri
}
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
}
@ -157,22 +152,3 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
/**
* Parse a redirect URI to extract port and path for the callback server.
* Returns defaults if the URI can't be parsed.
*/
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
if (!redirectUri) {
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
}
try {
const url = new URL(redirectUri)
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
const path = url.pathname || OAUTH_CALLBACK_PATH
return { port, path }
} catch {
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
}
}

View file

@ -1,6 +1,7 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util/log"
export namespace Patch {
@ -177,8 +178,18 @@ export namespace Patch {
return { content, nextIdx: i }
}
function stripHeredoc(input: string): string {
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
if (heredocMatch) {
return heredocMatch[2]
}
return input
}
export function parsePatch(patchText: string): { hunks: Hunk[] } {
const lines = patchText.split("\n")
const cleaned = stripHeredoc(patchText.trim())
const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
@ -301,7 +312,7 @@ export namespace Patch {
// Read original file content
let originalContent: string
try {
originalContent = require("fs").readFileSync(filePath, "utf-8")
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`)
}
@ -363,7 +374,7 @@ export namespace Patch {
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
let found = seekSequence(originalLines, pattern, lineIndex)
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
@ -371,7 +382,7 @@ export namespace Patch {
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
found = seekSequence(originalLines, pattern, lineIndex)
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
@ -407,28 +418,75 @@ export namespace Patch {
return result
}
function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
if (pattern.length === 0) return -1
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
// Simple substring search implementation
type Comparator = (a: string, b: string) => boolean
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
// If EOF anchor, try matching from end of file first
if (eof) {
const fromEnd = lines.length - pattern.length
if (fromEnd >= startIndex) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[fromEnd + j], pattern[j])) {
matches = false
break
}
}
if (matches) return fromEnd
}
}
// Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (lines[i + j] !== pattern[j]) {
if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
if (matches) {
return i
}
if (matches) return i
}
return -1
}
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
if (pattern.length === 0) return -1
// Pass 1: exact match
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
if (exact !== -1) return exact
// Pass 2: rstrip (trim trailing whitespace)
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
if (rstrip !== -1) return rstrip
// Pass 3: trim (both ends)
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
if (trim !== -1) return trim
// Pass 4: normalized (Unicode punctuation to ASCII)
const normalized = tryMatch(
lines,
pattern,
startIndex,
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
eof,
)
return normalized
}
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")

View file

@ -3,6 +3,9 @@ import { Installation } from "@/installation"
import { iife } from "@/util/iife"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
// slightly too early due to clock skew / timer drift.
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds
function normalizeDomain(url: string) {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
@ -204,6 +207,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const data = (await response.json()) as {
access_token?: string
error?: string
interval?: number
}
if (data.access_token) {
@ -230,13 +234,29 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error === "slow_down") {
// Based on the RFC spec, we must add 5 seconds to our current polling interval.
// (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5)
let newInterval = (deviceData.interval + 5) * 1000
// GitHub OAuth API may return the new interval in seconds in the response.
// We should try to use that if provided with safety margin.
const serverInterval = data.interval
if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) {
newInterval = serverInterval * 1000
}
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},

View file

@ -11,6 +11,8 @@ import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
@ -22,6 +24,8 @@ export async function InstanceBootstrap() {
FileWatcher.init()
File.init()
Vcs.init()
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View file

@ -317,4 +317,19 @@ export namespace Project {
}
return valid
}
export async function removeSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => {
const sandboxes = draft.sandboxes ?? []
draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
draft.time.updated = Date.now()
})
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
}
}

View file

@ -41,6 +41,18 @@ import { ProviderTransform } from "./transform"
export namespace Provider {
const log = Log.create({ service: "provider" })
function isGpt5OrLater(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) {
return false
}
return Number(match[1]) >= 5
}
function shouldUseCopilotResponsesApi(modelID: string): boolean {
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
@ -120,10 +132,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (modelID.includes("codex")) {
return sdk.responses(modelID)
}
return sdk.chat(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}
@ -132,10 +141,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (modelID.includes("codex")) {
return sdk.responses(modelID)
}
return sdk.chat(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}
@ -595,7 +601,10 @@ export namespace Provider {
api: {
id: model.id,
url: provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
npm: iife(() => {
if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
}),
},
status: model.status ?? "active",
headers: model.headers ?? {},
@ -902,16 +911,6 @@ export namespace Provider {
continue
}
if (providerID === "github-copilot" || providerID === "github-copilot-enterprise") {
provider.models = mapValues(provider.models, (model) => ({
...model,
api: {
...model.api,
npm: "@ai-sdk/github-copilot",
},
}))
}
const configProvider = config.provider?.[providerID]
for (const [modelID, model] of Object.entries(provider.models)) {
@ -999,6 +998,24 @@ export namespace Provider {
opts.signal = combined
}
// Strip openai itemId metadata following what codex does
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
// Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
// IDs are only re-attached for Azure with store=true
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
const body = JSON.parse(opts.body as string)
const isAzure = model.providerID.includes("azure")
const keepIds = isAzure && body.store === true
if (!keepIds && Array.isArray(body.input)) {
for (const item of body.input) {
if ("id" in item) {
delete item.id
}
}
opts.body = JSON.stringify(body)
}
}
return fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682

View file

@ -815,14 +815,20 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
// flag that checks if there have been client-side tool calls (not executed by openai)
let hasFunctionCall = false
// Track reasoning by output_index instead of item_id
// GitHub Copilot rotates encrypted item IDs on every event
const activeReasoning: Record<
string,
number,
{
canonicalId: string // the item.id from output_item.added
encryptedContent?: string | null
summaryParts: number[]
}
> = {}
// Track current active reasoning output_index for correlating summary events
let currentReasoningOutputIndex: number | null = null
// Track a stable text part id for the current assistant message.
// Copilot may change item_id across text deltas; normalize to one id.
let currentTextId: string | null = null
@ -933,10 +939,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
},
})
} else if (isResponseOutputItemAddedReasoningChunk(value)) {
activeReasoning[value.item.id] = {
activeReasoning[value.output_index] = {
canonicalId: value.item.id,
encryptedContent: value.item.encrypted_content,
summaryParts: [0],
}
currentReasoningOutputIndex = value.output_index
controller.enqueue({
type: "reasoning-start",
@ -1091,22 +1099,25 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
currentTextId = null
}
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id]
const activeReasoningPart = activeReasoning[value.output_index]
if (activeReasoningPart) {
for (const summaryIndex of activeReasoningPart.summaryParts) {
controller.enqueue({
type: "reasoning-end",
id: `${value.item.id}:${summaryIndex}`,
id: `${activeReasoningPart.canonicalId}:${summaryIndex}`,
providerMetadata: {
openai: {
itemId: value.item.id,
itemId: activeReasoningPart.canonicalId,
reasoningEncryptedContent: value.item.encrypted_content ?? null,
},
},
})
}
delete activeReasoning[value.output_index]
if (currentReasoningOutputIndex === value.output_index) {
currentReasoningOutputIndex = null
}
}
delete activeReasoning[value.item.id]
}
} else if (isResponseFunctionCallArgumentsDeltaChunk(value)) {
const toolCall = ongoingToolCalls[value.output_index]
@ -1198,32 +1209,40 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
logprobs.push(value.logprobs)
}
} else if (isResponseReasoningSummaryPartAddedChunk(value)) {
const activeItem =
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
// the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk.
if (value.summary_index > 0) {
activeReasoning[value.item_id]?.summaryParts.push(value.summary_index)
if (activeItem && value.summary_index > 0) {
activeItem.summaryParts.push(value.summary_index)
controller.enqueue({
type: "reasoning-start",
id: `${value.item_id}:${value.summary_index}`,
id: `${activeItem.canonicalId}:${value.summary_index}`,
providerMetadata: {
openai: {
itemId: value.item_id,
reasoningEncryptedContent: activeReasoning[value.item_id]?.encryptedContent ?? null,
itemId: activeItem.canonicalId,
reasoningEncryptedContent: activeItem.encryptedContent ?? null,
},
},
})
}
} else if (isResponseReasoningSummaryTextDeltaChunk(value)) {
controller.enqueue({
type: "reasoning-delta",
id: `${value.item_id}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
itemId: value.item_id,
const activeItem =
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
if (activeItem) {
controller.enqueue({
type: "reasoning-delta",
id: `${activeItem.canonicalId}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
itemId: activeItem.canonicalId,
},
},
},
})
})
}
} else if (isResponseFinishedChunk(value)) {
finishReason = mapOpenAIResponseFinishReason({
finishReason: value.response.incomplete_details?.reason,

View file

@ -16,30 +16,33 @@ function mimeToModality(mime: string): Modality | undefined {
}
export namespace ProviderTransform {
// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
case "@ai-sdk/github-copilot":
case "@ai-sdk/openai":
case "@ai-sdk/azure":
return "openai"
case "@ai-sdk/amazon-bedrock":
return "bedrock"
case "@ai-sdk/anthropic":
return "anthropic"
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
return "google"
case "@ai-sdk/gateway":
return "gateway"
case "@openrouter/ai-sdk-provider":
return "openrouter"
}
return undefined
}
function normalizeMessages(
msgs: ModelMessage[],
model: Provider.Model,
options: Record<string, unknown>,
): ModelMessage[] {
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" || options.store === false) {
msgs = msgs.map((msg) => {
if (msg.providerOptions?.openai) {
delete msg.providerOptions.openai["itemId"]
}
if (!Array.isArray(msg.content)) {
return msg
}
const content = msg.content.map((part) => {
if (part.providerOptions?.openai) {
delete part.providerOptions.openai["itemId"]
}
return part
})
return { ...msg, content } as typeof msg
})
}
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
@ -253,6 +256,28 @@ export namespace ProviderTransform {
msgs = applyCaching(msgs, model.providerID)
}
// Remap providerOptions keys from stored providerID to expected SDK key
const key = sdkKey(model.api.npm)
if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") {
const remap = (opts: Record<string, any> | undefined) => {
if (!opts) return opts
if (!(model.providerID in opts)) return opts
const result = { ...opts }
result[key] = result[model.providerID]
delete result[model.providerID]
return result
}
msgs = msgs.map((msg) => {
if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) }
return {
...msg,
providerOptions: remap(msg.providerOptions),
content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })),
} as typeof msg
})
}
return msgs
}
@ -300,15 +325,42 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {}
// see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
if (id.includes("grok") && id.includes("grok-3-mini")) {
if (model.api.npm === "@openrouter/ai-sdk-provider") {
return {
low: { reasoning: { effort: "low" } },
high: { reasoning: { effort: "high" } },
}
}
return {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}
}
if (id.includes("grok")) return {}
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {}
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) 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":
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
]),
)
case "@ai-sdk/cerebras":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
case "@ai-sdk/togetherai":
@ -484,7 +536,11 @@ export namespace ProviderTransform {
const result: Record<string, any> = {}
// openai and providers using openai package should set store to false by default.
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
if (
input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
result["store"] = false
}
@ -570,39 +626,8 @@ export namespace ProviderTransform {
}
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
switch (model.api.npm) {
case "@ai-sdk/github-copilot":
case "@ai-sdk/openai":
case "@ai-sdk/azure":
return {
["openai" as string]: options,
}
case "@ai-sdk/amazon-bedrock":
return {
["bedrock" as string]: options,
}
case "@ai-sdk/anthropic":
return {
["anthropic" as string]: options,
}
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
return {
["google" as string]: options,
}
case "@ai-sdk/gateway":
return {
["gateway" as string]: options,
}
case "@openrouter/ai-sdk-provider":
return {
["openrouter" as string]: options,
}
default:
return {
[model.providerID]: options,
}
}
const key = sdkKey(model.api.npm) ?? model.providerID
return { [key]: options }
}
export function maxOutputTokens(

View file

@ -10,7 +10,7 @@ export namespace Question {
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
@ -21,7 +21,7 @@ export namespace Question {
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().max(12).describe("Very short label (max 12 chars)"),
header: z.string().max(30).describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),

View file

@ -0,0 +1,61 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}

View file

@ -7,15 +7,17 @@ export namespace MDNS {
let bonjour: Bonjour | undefined
let currentPort: number | undefined
export function publish(port: number, name = "opencode") {
export function publish(port: number) {
if (currentPort === port) return
if (bonjour) unpublish()
try {
const name = `opencode-${port}`
bonjour = new Bonjour()
const service = bonjour.publish({
name,
type: "http",
host: "opencode.local",
port,
txt: { path: "/" },
})

View file

@ -1,79 +0,0 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import z from "zod"
import { errors } from "./error"
export const ProjectRoute = new Hono()
.get(
"/",
describeRoute({
summary: "List all projects",
description: "Get a list of projects that have been opened with OpenCode.",
operationId: "project.list",
responses: {
200: {
description: "List of projects",
content: {
"application/json": {
schema: resolver(Project.Info.array()),
},
},
},
},
}),
async (c) => {
const projects = await Project.list()
return c.json(projects)
},
)
.get(
"/current",
describeRoute({
summary: "Get current project",
description: "Retrieve the currently active project that OpenCode is working with.",
operationId: "project.current",
responses: {
200: {
description: "Current project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
},
}),
async (c) => {
return c.json(Instance.project)
},
)
.patch(
"/:projectID",
describeRoute({
summary: "Update project",
description: "Update project properties such as name, icon and color.",
operationId: "project.update",
responses: {
200: {
description: "Updated project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
...errors(400, 404),
},
}),
validator("param", z.object({ projectID: z.string() })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
return c.json(project)
},
)

View file

@ -1,95 +0,0 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../question"
import z from "zod"
import { errors } from "./error"
export const QuestionRoute = new Hono()
.get(
"/",
describeRoute({
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
operationId: "question.list",
responses: {
200: {
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.array()),
},
},
},
},
}),
async (c) => {
const questions = await Question.list()
return c.json(questions)
},
)
.post(
"/:requestID/reply",
describeRoute({
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
operationId: "question.reply",
responses: {
200: {
description: "Question answered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
.post(
"/:requestID/reject",
describeRoute({
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
operationId: "question.reject",
responses: {
200: {
description: "Question rejected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
return c.json(true)
},
)

View file

@ -0,0 +1,92 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config/config"
import { Provider } from "../../provider/provider"
import { mapValues } from "remeda"
import { errors } from "../error"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
const log = Log.create({ service: "server" })
export const ConfigRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "Get configuration",
description: "Retrieve the current OpenCode configuration settings and preferences.",
operationId: "config.get",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.patch(
"/",
describeRoute({
summary: "Update configuration",
description: "Update OpenCode configuration settings and preferences.",
operationId: "config.update",
responses: {
200: {
description: "Successfully updated config",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
...errors(400),
},
}),
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
return c.json(config)
},
)
.get(
"/providers",
describeRoute({
summary: "List config providers",
description: "Get a list of all configured AI providers and their default models.",
operationId: "config.providers",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
providers: Provider.Info.array(),
default: z.record(z.string(), z.string()),
}),
),
},
},
},
},
}),
async (c) => {
using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
})
},
),
)

View file

@ -0,0 +1,208 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { ToolRegistry } from "../../tool/registry"
import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ExperimentalRoutes = lazy(() =>
new Hono()
.get(
"/tool/ids",
describeRoute({
summary: "List tool IDs",
description:
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
operationId: "tool.ids",
responses: {
200: {
description: "Tool IDs",
content: {
"application/json": {
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
},
},
},
...errors(400),
},
}),
async (c) => {
return c.json(await ToolRegistry.ids())
},
)
.get(
"/tool",
describeRoute({
summary: "List tools",
description:
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
operationId: "tool.list",
responses: {
200: {
description: "Tools",
content: {
"application/json": {
schema: resolver(
z
.array(
z
.object({
id: z.string(),
description: z.string(),
parameters: z.any(),
})
.meta({ ref: "ToolListItem" }),
)
.meta({ ref: "ToolList" }),
),
},
},
},
...errors(400),
},
}),
validator(
"query",
z.object({
provider: z.string(),
model: z.string(),
}),
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
return c.json(
tools.map((t) => ({
id: t.id,
description: t.description,
// Handle both Zod schemas and plain JSON schemas
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
})),
)
},
)
.post(
"/worktree",
describeRoute({
summary: "Create worktree",
description: "Create a new git worktree for the current project.",
operationId: "worktree.create",
responses: {
200: {
description: "Worktree created",
content: {
"application/json": {
schema: resolver(Worktree.Info),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.create.schema),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
return c.json(worktree)
},
)
.get(
"/worktree",
describeRoute({
summary: "List worktrees",
description: "List all sandbox worktrees for the current project.",
operationId: "worktree.list",
responses: {
200: {
description: "List of worktree directories",
content: {
"application/json": {
schema: resolver(z.array(z.string())),
},
},
},
},
}),
async (c) => {
const sandboxes = await Project.sandboxes(Instance.project.id)
return c.json(sandboxes)
},
)
.delete(
"/worktree",
describeRoute({
summary: "Remove worktree",
description: "Remove a git worktree and delete its branch.",
operationId: "worktree.remove",
responses: {
200: {
description: "Worktree removed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.remove.schema),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
await Project.removeSandbox(Instance.project.id, body.directory)
return c.json(true)
},
)
.post(
"/worktree/reset",
describeRoute({
summary: "Reset worktree",
description: "Reset a worktree branch to the primary default branch.",
operationId: "worktree.reset",
responses: {
200: {
description: "Worktree reset",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.reset.schema),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
return c.json(true)
},
)
.get(
"/resource",
describeRoute({
summary: "Get MCP resources",
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
operationId: "experimental.resource.list",
responses: {
200: {
description: "MCP resources",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Resource)),
},
},
},
},
}),
async (c) => {
return c.json(await MCP.resources())
},
),
)

View file

@ -0,0 +1,197 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
import { Instance } from "../../project/instance"
import { lazy } from "../../util/lazy"
export const FileRoutes = lazy(() =>
new Hono()
.get(
"/find",
describeRoute({
summary: "Find text",
description: "Search for text patterns across files in the project using ripgrep.",
operationId: "find.text",
responses: {
200: {
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
validator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: Instance.directory,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
summary: "Find files",
description: "Search for files or directories by name or pattern in the project directory.",
operationId: "find.files",
responses: {
200: {
description: "File paths",
content: {
"application/json": {
schema: resolver(z.string().array()),
},
},
},
},
}),
validator(
"query",
z.object({
query: z.string(),
dirs: z.enum(["true", "false"]).optional(),
type: z.enum(["file", "directory"]).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type
const limit = c.req.valid("query").limit
const results = await File.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
})
return c.json(results)
},
)
.get(
"/find/symbol",
describeRoute({
summary: "Find symbols",
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
operationId: "find.symbols",
responses: {
200: {
description: "Symbols",
content: {
"application/json": {
schema: resolver(LSP.Symbol.array()),
},
},
},
},
}),
validator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([])
},
)
.get(
"/file",
describeRoute({
summary: "List files",
description: "List files and directories in a specified path.",
operationId: "file.list",
responses: {
200: {
description: "Files and directories",
content: {
"application/json": {
schema: resolver(File.Node.array()),
},
},
},
},
}),
validator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.list(path)
return c.json(content)
},
)
.get(
"/file/content",
describeRoute({
summary: "Read file",
description: "Read the content of a specified file.",
operationId: "file.read",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(File.Content),
},
},
},
},
}),
validator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
summary: "Get file status",
description: "Get the git status of all files in the project.",
operationId: "file.status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(File.Info.array()),
},
},
},
},
}),
async (c) => {
const content = await File.status()
return c.json(content)
},
),
)

View file

@ -0,0 +1,135 @@
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
export const GlobalRoutes = lazy(() =>
new Hono()
.get(
"/health",
describeRoute({
summary: "Get health",
description: "Get health information about the OpenCode server.",
operationId: "global.health",
responses: {
200: {
description: "Health information",
content: {
"application/json": {
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
},
},
},
},
}),
async (c) => {
return c.json({ healthy: true, version: Installation.VERSION })
},
)
.get(
"/event",
describeRoute({
summary: "Get global events",
description: "Subscribe to global events from the OpenCode system using server-sent events.",
operationId: "global.event",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
directory: z.string(),
payload: BusEvent.payloads(),
})
.meta({
ref: "GlobalEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global event connected")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
})
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event),
})
}
GlobalBus.on("event", handler)
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
})
}, 30000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
log.info("global event disconnected")
})
})
})
},
)
.post(
"/dispose",
describeRoute({
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
operationId: "global.dispose",
responses: {
200: {
description: "Global disposed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Instance.disposeAll()
GlobalBus.emit("event", {
directory: "global",
payload: {
type: GlobalDisposedEvent.type,
properties: {},
},
})
return c.json(true)
},
),
)

View file

@ -0,0 +1,225 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config/config"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const McpRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "Get MCP status",
description: "Get the status of all Model Context Protocol (MCP) servers.",
operationId: "mcp.status",
responses: {
200: {
description: "MCP server status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
},
}),
async (c) => {
return c.json(await MCP.status())
},
)
.post(
"/",
describeRoute({
summary: "Add MCP server",
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
operationId: "mcp.add",
responses: {
200: {
description: "MCP server added successfully",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
name: z.string(),
config: Config.Mcp,
}),
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config)
return c.json(result.status)
},
)
.post(
"/:name/auth",
describeRoute({
summary: "Start MCP OAuth",
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
operationId: "mcp.auth.start",
responses: {
200: {
description: "OAuth flow started",
content: {
"application/json": {
schema: resolver(
z.object({
authorizationUrl: z.string().describe("URL to open in browser for authorization"),
}),
),
},
},
},
...errors(400, 404),
},
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const result = await MCP.startAuth(name)
return c.json(result)
},
)
.post(
"/:name/auth/callback",
describeRoute({
summary: "Complete MCP OAuth",
description:
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
operationId: "mcp.auth.callback",
responses: {
200: {
description: "OAuth authentication completed",
content: {
"application/json": {
schema: resolver(MCP.Status),
},
},
},
...errors(400, 404),
},
}),
validator(
"json",
z.object({
code: z.string().describe("Authorization code from OAuth callback"),
}),
),
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await MCP.finishAuth(name, code)
return c.json(status)
},
)
.post(
"/:name/auth/authenticate",
describeRoute({
summary: "Authenticate MCP OAuth",
description: "Start OAuth flow and wait for callback (opens browser)",
operationId: "mcp.auth.authenticate",
responses: {
200: {
description: "OAuth authentication completed",
content: {
"application/json": {
schema: resolver(MCP.Status),
},
},
},
...errors(400, 404),
},
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const status = await MCP.authenticate(name)
return c.json(status)
},
)
.delete(
"/:name/auth",
describeRoute({
summary: "Remove MCP OAuth",
description: "Remove OAuth credentials for an MCP server",
operationId: "mcp.auth.remove",
responses: {
200: {
description: "OAuth credentials removed",
content: {
"application/json": {
schema: resolver(z.object({ success: z.literal(true) })),
},
},
},
...errors(404),
},
}),
async (c) => {
const name = c.req.param("name")
await MCP.removeAuth(name)
return c.json({ success: true as const })
},
)
.post(
"/:name/connect",
describeRoute({
description: "Connect an MCP server",
operationId: "mcp.connect",
responses: {
200: {
description: "MCP server connected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.connect(name)
return c.json(true)
},
)
.post(
"/:name/disconnect",
describeRoute({
description: "Disconnect an MCP server",
operationId: "mcp.disconnect",
responses: {
200: {
description: "MCP server disconnected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.disconnect(name)
return c.json(true)
},
),
)

View file

@ -0,0 +1,68 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PermissionRoutes = lazy(() =>
new Hono()
.post(
"/:requestID/reply",
describeRoute({
summary: "Respond to permission request",
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.reply",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
})
return c.json(true)
},
)
.get(
"/",
describeRoute({
summary: "List pending permissions",
description: "Get all pending permission requests across all sessions.",
operationId: "permission.list",
responses: {
200: {
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(PermissionNext.Request.array()),
},
},
},
},
}),
async (c) => {
const permissions = await PermissionNext.list()
return c.json(permissions)
},
),
)

View file

@ -0,0 +1,82 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ProjectRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List all projects",
description: "Get a list of projects that have been opened with OpenCode.",
operationId: "project.list",
responses: {
200: {
description: "List of projects",
content: {
"application/json": {
schema: resolver(Project.Info.array()),
},
},
},
},
}),
async (c) => {
const projects = await Project.list()
return c.json(projects)
},
)
.get(
"/current",
describeRoute({
summary: "Get current project",
description: "Retrieve the currently active project that OpenCode is working with.",
operationId: "project.current",
responses: {
200: {
description: "Current project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
},
}),
async (c) => {
return c.json(Instance.project)
},
)
.patch(
"/:projectID",
describeRoute({
summary: "Update project",
description: "Update project properties such as name, icon and color.",
operationId: "project.update",
responses: {
200: {
description: "Updated project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
...errors(400, 404),
},
}),
validator("param", z.object({ projectID: z.string() })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
return c.json(project)
},
),
)

View file

@ -0,0 +1,165 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config/config"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ProviderRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
operationId: "provider.list",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
all: ModelsDev.Provider.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),
),
},
},
},
},
}),
async (c) => {
const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = await ModelsDev.get()
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filteredProviders[key] = value
}
}
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
})
},
)
.get(
"/auth",
describeRoute({
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
operationId: "provider.auth",
responses: {
200: {
description: "Provider auth methods",
content: {
"application/json": {
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
},
},
},
},
}),
async (c) => {
return c.json(await ProviderAuth.methods())
},
)
.post(
"/:providerID/oauth/authorize",
describeRoute({
summary: "OAuth authorize",
description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
operationId: "provider.oauth.authorize",
responses: {
200: {
description: "Authorization URL and method",
content: {
"application/json": {
schema: resolver(ProviderAuth.Authorization.optional()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: z.string().meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method } = c.req.valid("json")
const result = await ProviderAuth.authorize({
providerID,
method,
})
return c.json(result)
},
)
.post(
"/:providerID/oauth/callback",
describeRoute({
summary: "OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
operationId: "provider.oauth.callback",
responses: {
200: {
description: "OAuth callback processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: z.string().meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
code: z.string().optional().meta({ description: "OAuth authorization code" }),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await ProviderAuth.callback({
providerID,
method,
code,
})
return c.json(true)
},
),
)

View file

@ -0,0 +1,169 @@
import { Hono } from "hono"
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 { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PtyRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
operationId: "pty.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Pty.Info.array()),
},
},
},
},
}),
async (c) => {
return c.json(Pty.list())
},
)
.post(
"/",
describeRoute({
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
operationId: "pty.create",
responses: {
200: {
description: "Created session",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(400),
},
}),
validator("json", Pty.CreateInput),
async (c) => {
const info = await Pty.create(c.req.valid("json"))
return c.json(info)
},
)
.get(
"/:ptyID",
describeRoute({
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
operationId: "pty.get",
responses: {
200: {
description: "Session info",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
throw new Storage.NotFoundError({ message: "Session not found" })
}
return c.json(info)
},
)
.put(
"/:ptyID",
describeRoute({
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
operationId: "pty.update",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(400),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
return c.json(info)
},
)
.delete(
"/:ptyID",
describeRoute({
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
operationId: "pty.remove",
responses: {
200: {
description: "Session removed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
async (c) => {
await Pty.remove(c.req.valid("param").ptyID)
return c.json(true)
},
)
.get(
"/:ptyID/connect",
describeRoute({
summary: "Connect to PTY session",
description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
operationId: "pty.connect",
responses: {
200: {
description: "Connected session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
upgradeWebSocket((c) => {
const id = c.req.param("ptyID")
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws)
},
onMessage(event) {
handler?.onMessage(String(event.data))
},
onClose() {
handler?.onClose()
},
}
}),
),
)

View file

@ -0,0 +1,98 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../../question"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const QuestionRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
operationId: "question.list",
responses: {
200: {
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.array()),
},
},
},
},
}),
async (c) => {
const questions = await Question.list()
return c.json(questions)
},
)
.post(
"/:requestID/reply",
describeRoute({
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
operationId: "question.reply",
responses: {
200: {
description: "Question answered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
.post(
"/:requestID/reject",
describeRoute({
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
operationId: "question.reject",
responses: {
200: {
description: "Question rejected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
return c.json(true)
},
),
)

View file

@ -0,0 +1,935 @@
import { Hono } from "hono"
import { stream } from "hono/streaming"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "../../session/prompt"
import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const log = Log.create({ service: "server" })
export const SessionRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
operationId: "session.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
},
}),
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
}),
),
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
sessions.push(session)
if (query.limit !== undefined && sessions.length >= query.limit) break
}
return c.json(sessions)
},
)
.get(
"/status",
describeRoute({
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
operationId: "session.status",
responses: {
200: {
description: "Get session status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), SessionStatus.Info)),
},
},
},
...errors(400),
},
}),
async (c) => {
const result = SessionStatus.list()
return c.json(result)
},
)
.get(
"/:sessionID",
describeRoute({
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
tags: ["Session"],
operationId: "session.get",
responses: {
200: {
description: "Get session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.get.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("SEARCH", { url: c.req.url })
const session = await Session.get(sessionID)
return c.json(session)
},
)
.get(
"/:sessionID/children",
describeRoute({
summary: "Get session children",
tags: ["Session"],
description: "Retrieve all child sessions that were forked from the specified parent session.",
operationId: "session.children",
responses: {
200: {
description: "List of children",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.children.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await Session.children(sessionID)
return c.json(session)
},
)
.get(
"/:sessionID/todo",
describeRoute({
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
operationId: "session.todo",
responses: {
200: {
description: "Todo list",
content: {
"application/json": {
schema: resolver(Todo.Info.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const todos = await Todo.get(sessionID)
return c.json(todos)
},
)
.post(
"/",
describeRoute({
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
operationId: "session.create",
responses: {
...errors(400),
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
validator("json", Session.create.schema.optional()),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await Session.create(body)
return c.json(session)
},
)
.delete(
"/:sessionID",
describeRoute({
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
operationId: "session.delete",
responses: {
200: {
description: "Successfully deleted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.remove.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.remove(sessionID)
return c.json(true)
},
)
.patch(
"/:sessionID",
describeRoute({
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
operationId: "session.update",
responses: {
200: {
description: "Successfully updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
validator(
"json",
z.object({
title: z.string().optional(),
time: z
.object({
archived: z.number().optional(),
})
.optional(),
}),
),
async (c) => {
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
})
return c.json(updatedSession)
},
)
.post(
"/:sessionID/init",
describeRoute({
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
operationId: "session.init",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", Session.initialize.schema.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await Session.initialize({ ...body, sessionID })
return c.json(true)
},
)
.post(
"/:sessionID/fork",
describeRoute({
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
operationId: "session.fork",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
validator(
"param",
z.object({
sessionID: Session.fork.schema.shape.sessionID,
}),
),
validator("json", Session.fork.schema.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await Session.fork({ ...body, sessionID })
return c.json(result)
},
)
.post(
"/:sessionID/abort",
describeRoute({
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
operationId: "session.abort",
responses: {
200: {
description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
SessionPrompt.cancel(c.req.valid("param").sessionID)
return c.json(true)
},
)
.post(
"/:sessionID/share",
describeRoute({
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
operationId: "session.share",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.share(sessionID)
const session = await Session.get(sessionID)
return c.json(session)
},
)
.get(
"/:sessionID/diff",
describeRoute({
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
operationId: "session.diff",
responses: {
200: {
description: "Successfully retrieved diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
},
}),
validator(
"param",
z.object({
sessionID: SessionSummary.diff.schema.shape.sessionID,
}),
),
validator(
"query",
z.object({
messageID: SessionSummary.diff.schema.shape.messageID,
}),
),
async (c) => {
const query = c.req.valid("query")
const params = c.req.valid("param")
const result = await SessionSummary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
})
return c.json(result)
},
)
.delete(
"/:sessionID/share",
describeRoute({
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
operationId: "session.unshare",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.unshare.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.unshare(sessionID)
const session = await Session.get(sessionID)
return c.json(session)
},
)
.post(
"/:sessionID/summarize",
describeRoute({
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
operationId: "session.summarize",
responses: {
200: {
description: "Summarized session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
auto: z.boolean().optional().default(false),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const session = await Session.get(sessionID)
await SessionRevert.cleanup(session)
const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || (await Agent.defaultAgent())
break
}
}
await SessionCompaction.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
await SessionPrompt.loop(sessionID)
return c.json(true)
},
)
.get(
"/:sessionID/message",
describeRoute({
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
operationId: "session.messages",
responses: {
200: {
description: "List of messages",
content: {
"application/json": {
schema: resolver(MessageV2.WithParts.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator(
"query",
z.object({
limit: z.coerce.number().optional(),
}),
),
async (c) => {
const query = c.req.valid("query")
const messages = await Session.messages({
sessionID: c.req.valid("param").sessionID,
limit: query.limit,
})
return c.json(messages)
},
)
.get(
"/:sessionID/message/:messageID",
describeRoute({
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
operationId: "session.message",
responses: {
200: {
description: "Message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Info,
parts: MessageV2.Part.array(),
}),
),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
}),
),
async (c) => {
const params = c.req.valid("param")
const message = await MessageV2.get({
sessionID: params.sessionID,
messageID: params.messageID,
})
return c.json(message)
},
)
.delete(
"/:sessionID/message/:messageID/part/:partID",
describeRoute({
description: "Delete a part from a message",
operationId: "part.delete",
responses: {
200: {
description: "Successfully deleted part",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
}),
),
async (c) => {
const params = c.req.valid("param")
await Session.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
return c.json(true)
},
)
.patch(
"/:sessionID/message/:messageID/part/:partID",
describeRoute({
description: "Update a part in a message",
operationId: "part.update",
responses: {
200: {
description: "Successfully updated part",
content: {
"application/json": {
schema: resolver(MessageV2.Part),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
}),
),
validator("json", MessageV2.Part),
async (c) => {
const params = c.req.valid("param")
const body = c.req.valid("json")
if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) {
throw new Error(
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
const part = await Session.updatePart(body)
return c.json(part)
},
)
.post(
"/:sessionID/message",
describeRoute({
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
operationId: "session.prompt",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Assistant,
parts: MessageV2.Part.array(),
}),
),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
c.status(200)
c.header("Content-Type", "application/json")
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
stream.write(JSON.stringify(msg))
})
},
)
.post(
"/:sessionID/prompt_async",
describeRoute({
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
operationId: "session.prompt_async",
responses: {
204: {
description: "Prompt accepted",
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
})
},
)
.post(
"/:sessionID/command",
describeRoute({
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
operationId: "session.command",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Assistant,
parts: MessageV2.Part.array(),
}),
),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.command({ ...body, sessionID })
return c.json(msg)
},
)
.post(
"/:sessionID/shell",
describeRoute({
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
operationId: "session.shell",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(MessageV2.Assistant),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.shell({ ...body, sessionID })
return c.json(msg)
},
)
.post(
"/:sessionID/revert",
describeRoute({
summary: "Revert message",
description: "Revert a specific message in a session, undoing its effects and restoring the previous state.",
operationId: "session.revert",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
const session = await SessionRevert.revert({
sessionID,
...c.req.valid("json"),
})
return c.json(session)
},
)
.post(
"/:sessionID/unrevert",
describeRoute({
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
operationId: "session.unrevert",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await SessionRevert.unrevert({ sessionID })
return c.json(session)
},
)
.post(
"/:sessionID/permissions/:permissionID",
describeRoute({
summary: "Respond to permission",
deprecated: true,
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.respond",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
permissionID: z.string(),
}),
),
validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
PermissionNext.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
return c.json(true)
},
),
)

View file

@ -0,0 +1,379 @@
import { Hono, type Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Bus } from "../../bus"
import { Session } from "../../session"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<any>()
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
request.push({
path: ctx.req.path,
body,
})
return response.next()
}
const TuiControlRoutes = new Hono()
.get(
"/next",
describeRoute({
summary: "Get next TUI request",
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
operationId: "tui.control.next",
responses: {
200: {
description: "Next TUI request",
content: {
"application/json": {
schema: resolver(TuiRequest),
},
},
},
},
}),
async (c) => {
const req = await request.next()
return c.json(req)
},
)
.post(
"/response",
describeRoute({
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
operationId: "tui.control.response",
responses: {
200: {
description: "Response submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", z.any()),
async (c) => {
const body = c.req.valid("json")
response.push(body)
return c.json(true)
},
)
export const TuiRoutes = lazy(() =>
new Hono()
.post(
"/append-prompt",
describeRoute({
summary: "Append TUI prompt",
description: "Append prompt to the TUI",
operationId: "tui.appendPrompt",
responses: {
200: {
description: "Prompt processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", TuiEvent.PromptAppend.properties),
async (c) => {
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
return c.json(true)
},
)
.post(
"/open-help",
describeRoute({
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
operationId: "tui.openHelp",
responses: {
200: {
description: "Help dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "help.show",
})
return c.json(true)
},
)
.post(
"/open-sessions",
describeRoute({
summary: "Open sessions dialog",
description: "Open the session dialog",
operationId: "tui.openSessions",
responses: {
200: {
description: "Session dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
.post(
"/open-themes",
describeRoute({
summary: "Open themes dialog",
description: "Open the theme dialog",
operationId: "tui.openThemes",
responses: {
200: {
description: "Theme dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
.post(
"/open-models",
describeRoute({
summary: "Open models dialog",
description: "Open the model dialog",
operationId: "tui.openModels",
responses: {
200: {
description: "Model dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "model.list",
})
return c.json(true)
},
)
.post(
"/submit-prompt",
describeRoute({
summary: "Submit TUI prompt",
description: "Submit the prompt",
operationId: "tui.submitPrompt",
responses: {
200: {
description: "Prompt submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "prompt.submit",
})
return c.json(true)
},
)
.post(
"/clear-prompt",
describeRoute({
summary: "Clear TUI prompt",
description: "Clear the prompt",
operationId: "tui.clearPrompt",
responses: {
200: {
description: "Prompt cleared successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "prompt.clear",
})
return c.json(true)
},
)
.post(
"/execute-command",
describeRoute({
summary: "Execute TUI command",
description: "Execute a TUI command (e.g. agent_cycle)",
operationId: "tui.executeCommand",
responses: {
200: {
description: "Command executed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", z.object({ command: z.string() })),
async (c) => {
const command = c.req.valid("json").command
await Bus.publish(TuiEvent.CommandExecute, {
// @ts-expect-error
command: {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
}[command],
})
return c.json(true)
},
)
.post(
"/show-toast",
describeRoute({
summary: "Show TUI toast",
description: "Show a toast notification in the TUI",
operationId: "tui.showToast",
responses: {
200: {
description: "Toast notification shown successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", TuiEvent.ToastShow.properties),
async (c) => {
await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
return c.json(true)
},
)
.post(
"/publish",
describeRoute({
summary: "Publish TUI event",
description: "Publish a TUI event",
operationId: "tui.publish",
responses: {
200: {
description: "Event published successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.union(
Object.values(TuiEvent).map((def) => {
return z
.object({
type: z.literal(def.type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
}),
),
),
async (c) => {
const evt = c.req.valid("json")
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
return c.json(true)
},
)
.post(
"/select-session",
describeRoute({
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
operationId: "tui.selectSession",
responses: {
200: {
description: "Session selected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await Session.get(sessionID)
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
)
.route("/control", TuiControlRoutes),
)

File diff suppressed because it is too large Load diff

View file

@ -1,71 +0,0 @@
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { z } from "zod"
import { AsyncQueue } from "../util/queue"
const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<any>()
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
request.push({
path: ctx.req.path,
body,
})
return response.next()
}
export const TuiRoute = new Hono()
.get(
"/next",
describeRoute({
summary: "Get next TUI request",
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
operationId: "tui.control.next",
responses: {
200: {
description: "Next TUI request",
content: {
"application/json": {
schema: resolver(TuiRequest),
},
},
},
},
}),
async (c) => {
const req = await request.next()
return c.json(req)
},
)
.post(
"/response",
describeRoute({
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
operationId: "tui.control.response",
responses: {
200: {
description: "Response submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", z.any()),
async (c) => {
const body = c.req.valid("json")
response.push(body)
return c.json(true)
},
)

View file

@ -1,14 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import {
APICallError,
convertToModelMessages,
LoadAPIKeyError,
type ModelMessage,
type UIMessage,
type ToolSet,
} from "ai"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
@ -439,7 +432,7 @@ export namespace MessageV2 {
})
export type WithParts = z.infer<typeof WithParts>
export function toModelMessage(input: WithParts[], options?: { tools?: ToolSet }): ModelMessage[] {
export function toModelMessage(input: WithParts[]): ModelMessage[] {
const result: UIMessage[] = []
for (const msg of input) {
@ -510,14 +503,30 @@ export namespace MessageV2 {
})
if (part.type === "tool") {
if (part.state.status === "completed") {
if (part.state.attachments?.length) {
result.push({
id: Identifier.ascending("message"),
role: "user",
parts: [
{
type: "text",
text: `The tool ${part.tool} returned the following attachments:`,
},
...part.state.attachments.map((attachment) => ({
type: "file" as const,
url: attachment.url,
mediaType: attachment.mime,
filename: attachment.filename,
})),
],
})
}
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output: part.state.time.compacted
? "[Old tool result content cleared]"
: { output: part.state.output, attachments: part.state.attachments },
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
callProviderMetadata: part.metadata,
})
}
@ -556,12 +565,7 @@ export namespace MessageV2 {
}
}
return convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{
tools: options?.tools,
},
)
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {

View file

@ -597,7 +597,7 @@ export namespace SessionPrompt {
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
messages: [
...MessageV2.toModelMessage(sessionMessages, { tools }),
...MessageV2.toModelMessage(sessionMessages),
...(isLastStep
? [
{
@ -685,7 +685,10 @@ export namespace SessionPrompt {
},
})
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
for (const item of await ToolRegistry.tools(
{ modelID: input.model.api.id, providerID: input.model.providerID },
input.agent,
)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@ -716,18 +719,10 @@ export namespace SessionPrompt {
)
return result
},
toModelOutput(result: { output: string; attachments?: MessageV2.FilePart[] }) {
if (!result.attachments?.length) return { type: "text", value: result.output }
toModelOutput(result) {
return {
type: "content",
value: [
{ type: "text", text: result.output },
...result.attachments.map((a) => ({
type: "media" as const,
data: a.url.slice(a.url.indexOf(",") + 1),
mediaType: a.mime,
})),
],
type: "text",
value: result.output,
}
},
})
@ -814,18 +809,10 @@ export namespace SessionPrompt {
content: result.content, // directly return content to preserve ordering when outputting to model
}
}
item.toModelOutput = (result: { output: string; attachments?: MessageV2.FilePart[] }) => {
if (!result.attachments?.length) return { type: "text", value: result.output }
item.toModelOutput = (result) => {
return {
type: "content",
value: [
{ type: "text", text: result.output },
...result.attachments.map((a) => ({
type: "media" as const,
data: a.url.slice(a.url.indexOf(",") + 1),
mediaType: a.mime,
})),
],
type: "text",
value: result.output,
}
}
tools[key] = item
@ -1715,6 +1702,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
: await lastModel(input.sessionID)
: taskModel
await Plugin.trigger(
"command.execute.before",
{
command: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
},
{ parts },
)
const result = (await prompt({
sessionID: input.sessionID,
messageID: input.messageID,

View file

@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:

View file

@ -6,9 +6,46 @@ import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function track() {
if (Instance.project.vcs !== "git") return

View file

@ -0,0 +1,278 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./apply_patch.txt"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.define("apply_patch", {
description: DESCRIPTION,
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
throw new Error("patch rejected: empty patch")
}
throw new Error("apply_patch verification failed: no hunks found")
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
case "update": {
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
}
// Read file and update time tracking (like edit tool does)
await FileTime.assert(ctx.sessionID, filePath)
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
case "delete": {
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`apply_patch verification failed: ${error}`)
})
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
}
}
// Check permissions if needed
await ctx.ask({
permission: "edit",
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
always: ["*"],
metadata: {
diff: totalDiff,
},
})
// Apply the changes
const changedFiles: string[] = []
for (const change of fileChanges) {
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "move":
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
await fs.writeFile(change.movePath, change.newContent, "utf-8")
await fs.unlink(change.filePath)
changedFiles.push(change.movePath)
}
break
case "delete":
await fs.unlink(change.filePath)
changedFiles.push(change.filePath)
break
}
// Update file time tracking
FileTime.read(ctx.sessionID, change.filePath)
if (change.movePath) {
FileTime.read(ctx.sessionID, change.movePath)
}
}
// Publish file change events
for (const filePath of changedFiles) {
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
}
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
await LSP.touchFile(target, true)
}
const diagnostics = await LSP.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath)}`
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath)}`
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target)}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
// Report LSP errors for changed files
const MAX_DIAGNOSTICS_PER_FILE = 20
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const normalized = Filesystem.normalizePath(target)
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
// Build per-file metadata for UI rendering
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
type: change.type,
diff: change.diff,
before: change.oldContent,
after: change.newContent,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
}
},
})

View file

@ -0,0 +1,33 @@
Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
Example patch:
```
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file

View file

@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => {
const discardedCalls = params.tool_calls.slice(10)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools("")
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {

View file

@ -1,201 +0,0 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch } from "diff"
import { assertExternalDirectory } from "./external-directory"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const PatchTool = Tool.define("patch", {
description:
"Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`Failed to parse patch: ${error}`)
}
if (hunks.length === 0) {
throw new Error("No file changes found in patch")
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
}> = []
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add":
if (hunk.type === "add") {
const oldContent = ""
const newContent = hunk.contents
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
})
totalDiff += diff + "\n"
}
break
case "update":
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`File not found or is directory: ${filePath}`)
}
// Read file and update time tracking (like edit tool does)
await FileTime.assert(ctx.sessionID, filePath)
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`Failed to apply update to ${filePath}: ${error}`)
}
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
})
totalDiff += diff + "\n"
break
case "delete":
// Check if file exists for deletion
await FileTime.assert(ctx.sessionID, filePath)
const contentToDelete = await fs.readFile(filePath, "utf-8")
const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
})
totalDiff += deleteDiff + "\n"
break
}
}
// Check permissions if needed
await ctx.ask({
permission: "edit",
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
always: ["*"],
metadata: {
diff: totalDiff,
},
})
// Apply the changes
const changedFiles: string[] = []
for (const change of fileChanges) {
switch (change.type) {
case "add":
// Create parent directories
const addDir = path.dirname(change.filePath)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
changedFiles.push(change.filePath)
break
case "move":
if (change.movePath) {
// Create parent directories for destination
const moveDir = path.dirname(change.movePath)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
// Write to new location
await fs.writeFile(change.movePath, change.newContent, "utf-8")
// Remove original
await fs.unlink(change.filePath)
changedFiles.push(change.movePath)
}
break
case "delete":
await fs.unlink(change.filePath)
changedFiles.push(change.filePath)
break
}
// Update file time tracking
FileTime.read(ctx.sessionID, change.filePath)
if (change.movePath) {
FileTime.read(ctx.sessionID, change.movePath)
}
}
// Publish file change events
for (const filePath of changedFiles) {
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
}
// Generate output summary
const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
const summary = `${fileChanges.length} files changed`
return {
title: summary,
metadata: {
diff: totalDiff,
},
output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
}
},
})

View file

@ -1 +0,0 @@
do not use

View file

@ -59,7 +59,9 @@ export const ReadTool = Tool.define("read", {
throw new Error(`File not found: ${filepath}`)
}
const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml"
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const isImage =
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
const isPdf = file.type === "application/pdf"
if (isImage || isPdf) {
const mime = file.type

View file

@ -26,6 +26,7 @@ import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { ApplyPatchTool } from "./apply_patch"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@ -108,6 +109,7 @@ export namespace ToolRegistry {
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
@ -119,15 +121,33 @@ export namespace ToolRegistry {
return all().then((x) => x.map((t) => t.id))
}
export async function tools(providerID: string, agent?: Agent.Info) {
export async function tools(
model: {
providerID: string
modelID: string
},
agent?: Agent.Info,
) {
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
// omit todo tools for openai models
if (t.id === "todoread" || t.id === "todowrite") {
if (model.modelID.includes("gpt-")) return false
}
return true
})
.map(async (t) => {

View file

@ -2,9 +2,9 @@ import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { lazy } from "../util/lazy"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
export namespace Truncate {
export const MAX_LINES = 2000
@ -12,6 +12,7 @@ export namespace Truncate {
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
@ -21,6 +22,15 @@ export namespace Truncate {
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const glob = new Bun.Glob("tool_*")
@ -31,8 +41,6 @@ export namespace Truncate {
}
}
const init = lazy(cleanup)
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
@ -81,7 +89,6 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
await init()
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)

View file

@ -33,6 +33,26 @@ export namespace Worktree {
export type CreateInput = z.infer<typeof CreateInput>
export const RemoveInput = z
.object({
directory: z.string(),
})
.meta({
ref: "WorktreeRemoveInput",
})
export type RemoveInput = z.infer<typeof RemoveInput>
export const ResetInput = z
.object({
directory: z.string(),
})
.meta({
ref: "WorktreeResetInput",
})
export type ResetInput = z.infer<typeof ResetInput>
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
z.object({
@ -61,6 +81,20 @@ export namespace Worktree {
}),
)
export const RemoveFailedError = NamedError.create(
"WorktreeRemoveFailedError",
z.object({
message: z.string(),
}),
)
export const ResetFailedError = NamedError.create(
"WorktreeResetFailedError",
z.object({
message: z.string(),
}),
)
const ADJECTIVES = [
"brave",
"calm",
@ -214,4 +248,177 @@ export namespace Worktree {
return info
})
export const remove = fn(RemoveInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = path.resolve(input.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 = entries.find((item) => item.path && path.resolve(item.path) === directory)
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
}
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 branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
}
return true
})
export const reset = fn(ResetInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = path.resolve(input.directory)
if (directory === path.resolve(Instance.worktree)) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new ResetFailedError({ 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 = entries.find((item) => item.path && path.resolve(item.path) === directory)
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
const remotes = outputText(remoteList.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const remoteHead = remote
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
: { exitCode: 1, stdout: undefined, stderr: undefined }
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
if (remoteBranch) {
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
}
if (!entry.path) {
throw new ResetFailedError({ message: "Worktree path not found" })
}
const worktreePath = entry.path
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
}
const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath)
if (clean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
}
const dirty = outputText(status.stdout)
if (!dirty) return true
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
})
}

Some files were not shown because too many files have changed in this diff Show more