Compare commits

...

22 Commits

Author SHA1 Message Date
opencode 3d31ae28b0 release: v1.14.23 2026-04-24 13:17:01 +00:00
Aiden Cline f8e939d96f fix: support max for deepseek (#24163) 2026-04-24 08:48:52 -04:00
黑墨水鱼 923af96d26 fix: preserve empty reasoning_content for DeepSeek V4 thinking mode (#24146)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-04-24 08:42:57 -04:00
Aiden Cline a882e958b3 fix: deepseek variants (#24157) 2026-04-24 08:34:48 -04:00
Simon Klee 2cda629c8d test(prompt): align shell placeholder expectation (#24147) 2026-04-24 13:00:11 +02:00
Brendan Allan f033d2d8fb fix(app): conditionally show model variant selector (#24115) 2026-04-24 15:20:53 +08:00
Frank a4bd88ab97 zen: deepseek v4 pro 2026-04-24 02:09:14 -04:00
Frank f4616c8268 sync 2026-04-24 02:05:22 -04:00
Brendan Allan 4712f0f3c1 feat(prompt): add shell mode UI with cancel button, custom icon, and example placeholder (#24105) 2026-04-24 14:04:55 +08:00
opencode-agent[bot] 6c1268f3b1 chore: generate 2026-04-24 05:28:43 +00:00
Brendan Allan 2e156b8990 fix(desktop): avoid relaunching without installing updates (#23806) 2026-04-24 13:27:36 +08:00
Brendan Allan 3bfe6a1ef6 ci: add platform-specific bun install flags (#23822) 2026-04-24 04:50:34 +00:00
opencode-agent[bot] 5c5069b622 chore: generate 2026-04-23 21:44:44 +00:00
rahul f8c6ddd4cb feat(truncate): allow configuring tool output truncation limits (#23770)
Co-authored-by: rgs_ramp <rgs@ramp.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-23 17:43:33 -04:00
Kit Langton e50a688ca3 feat(httpapi): bridge workspace read endpoints (#24062) 2026-04-23 17:32:02 -04:00
Aiden Cline 334ab4707c fix: account for additional openai retry case (#24063) 2026-04-23 17:31:43 -04:00
Aiden Cline 87321942fe chore: update copilot readme to symlink to an agents md to prevent dumbass agents from touching these files (#24057) 2026-04-23 17:08:46 -04:00
opencode-agent[bot] a771859362 chore: generate 2026-04-23 20:50:19 +00:00
Kit Langton 31d01d404a refactor(control-plane): migrate workspace DTO schemas (#24056) 2026-04-23 16:48:56 -04:00
Kit Langton 814e83ffec docs: update effect schema migration tracker (#24054) 2026-04-23 16:23:45 -04:00
opencode-agent[bot] 4c3e65c877 chore: generate 2026-04-23 20:20:26 +00:00
James Long 98ea5b6e7e feat(tui): support builtin protocol for handling context from editors (#24034) 2026-04-23 16:19:19 -04:00
96 changed files with 1106 additions and 319 deletions
+7 -2
View File
@@ -1,5 +1,10 @@
name: "Setup Bun"
description: "Setup Bun with caching and install dependencies"
inputs:
install-flags:
description: "Additional flags to pass to 'bun install'"
required: false
default: ""
runs:
using: "composite"
steps:
@@ -46,8 +51,8 @@ runs:
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
bun install --linker hoisted ${{ inputs.install-flags }}
else
bun install
bun install ${{ inputs.install-flags }}
fi
shell: bash
+6 -2
View File
@@ -402,12 +402,14 @@ jobs:
fail-fast: false
matrix:
settings:
- host: macos-latest
- host: macos-26-intel
target: x86_64-apple-darwin
platform_flag: --mac --x64
- host: macos-latest
bun_install_flags: --os=darwin --cpu=x64
- host: macos-26
target: aarch64-apple-darwin
platform_flag: --mac --arm64
bun_install_flags: --os=darwin --cpu=arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
@@ -437,6 +439,8 @@ jobs:
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
with:
install-flags: ${{ matrix.settings.bun_install_flags }}
- name: Azure login
if: runner.os == 'Windows'
+16 -16
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -269,7 +269,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -314,7 +314,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.22",
"version": "1.14.23",
"bin": {
"opencode": "./bin/opencode",
},
@@ -459,7 +459,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -494,7 +494,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -509,7 +509,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.14.22",
"version": "1.14.23",
"bin": {
"opencode": "./bin/opencode",
},
@@ -533,7 +533,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -568,7 +568,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.22",
"version": "1.14.23",
"description": "",
"type": "module",
"exports": {
+46 -35
View File
@@ -270,7 +270,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
transform: `scale(${0.95 + value * 0.05})`,
transform: `scale(${0.98 + value * 0.02})`,
filter: `blur(${(1 - value) * 2}px)`,
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
})
@@ -345,7 +345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "",
suggest: suggest(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
@@ -1403,12 +1403,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!working() && blank())}
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : "arrow-up"}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1451,14 +1450,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
style={{
padding: "0 4px 0 8px",
padding: "0 0px 0 8px",
...shell(),
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
<Icon name="console" />
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
<div class="flex-1" />
<Button
variant="ghost"
class="text-text-base"
onClick={() => {
setStore("mode", "normal")
}}
>
{language.t("common.cancel")}
</Button>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<Show when={!agentsLoading()}>
@@ -1565,33 +1574,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</Show>
</Show>
</div>
@@ -12,7 +12,7 @@ describe("promptPlaceholder", () => {
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.shell")
expect(value).toBe("prompt.placeholder.shell:example")
})
test("returns summarize placeholders for comment context", () => {
@@ -7,7 +7,7 @@ type PromptPlaceholderInput = {
}
export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example })
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")
@@ -128,27 +128,25 @@ export const SettingsGeneral: Component = () => {
return
}
const actions =
platform.update && platform.restart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.update!()
await platform.restart!()
},
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,
+3 -3
View File
@@ -49,11 +49,11 @@ export type Platform = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */
/** Check for a downloadable desktop update */
checkUpdate?(): Promise<UpdateInfo>
/** Install updates (Tauri only) */
update?(): Promise<void>
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Fetch override */
fetch?: typeof fetch
+1 -1
View File
@@ -210,7 +210,7 @@ export const dict = {
"common.saving": "جارٍ الحفظ...",
"common.default": "افتراضي",
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.shell": "أدخل أمر shell... {{example}}",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
+1 -1
View File
@@ -210,7 +210,7 @@ export const dict = {
"common.saving": "Salvando...",
"common.default": "Padrão",
"common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.shell": "Digite comando do shell... {{example}}",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
"prompt.placeholder.summarizeComments": "Resumir comentários…",
+1 -1
View File
@@ -228,7 +228,7 @@ export const dict = {
"common.default": "Podrazumijevano",
"common.attachment": "prilog",
"prompt.placeholder.shell": "Unesi shell naredbu...",
"prompt.placeholder.shell": "Unesi shell naredbu... {{example}}",
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
"prompt.placeholder.simple": "Pitaj bilo šta...",
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
+1 -1
View File
@@ -226,7 +226,7 @@ export const dict = {
"common.default": "Standard",
"common.attachment": "vedhæftning",
"prompt.placeholder.shell": "Indtast shell-kommando...",
"prompt.placeholder.shell": "Indtast shell-kommando... {{example}}",
"prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"',
"prompt.placeholder.simple": "Spørg om hvad som helst...",
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
+1 -1
View File
@@ -215,7 +215,7 @@ export const dict = {
"common.saving": "Speichert...",
"common.default": "Standard",
"common.attachment": "Anhang",
"prompt.placeholder.shell": "Shell-Befehl eingeben...",
"prompt.placeholder.shell": "Shell-Befehl eingeben... {{example}}",
"prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"',
"prompt.placeholder.simple": "Fragen Sie alles...",
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
+1 -1
View File
@@ -230,7 +230,7 @@ export const dict = {
"common.default": "Default",
"common.attachment": "attachment",
"prompt.placeholder.shell": "Enter shell command...",
"prompt.placeholder.shell": "Enter shell command... {{example}}",
"prompt.placeholder.normal": 'Ask anything... "{{example}}"',
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.summarizeComments": "Summarize comments…",
+1 -1
View File
@@ -227,7 +227,7 @@ export const dict = {
"common.default": "Predeterminado",
"common.attachment": "adjunto",
"prompt.placeholder.shell": "Introduce comando de shell...",
"prompt.placeholder.shell": "Introduce comando de shell... {{example}}",
"prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"',
"prompt.placeholder.simple": "Pregunta cualquier cosa...",
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
+1 -1
View File
@@ -210,7 +210,7 @@ export const dict = {
"common.saving": "Enregistrement...",
"common.default": "Défaut",
"common.attachment": "pièce jointe",
"prompt.placeholder.shell": "Entrez une commande shell...",
"prompt.placeholder.shell": "Entrez une commande shell... {{example}}",
"prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"',
"prompt.placeholder.simple": "Demandez n'importe quoi...",
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
+1 -1
View File
@@ -209,7 +209,7 @@ export const dict = {
"common.saving": "保存中...",
"common.default": "デフォルト",
"common.attachment": "添付ファイル",
"prompt.placeholder.shell": "シェルコマンドを入力...",
"prompt.placeholder.shell": "シェルコマンドを入力... {{example}}",
"prompt.placeholder.normal": '何でも聞いてください... "{{example}}"',
"prompt.placeholder.simple": "何でも聞いてください...",
"prompt.placeholder.summarizeComments": "コメントを要約…",
+1 -1
View File
@@ -209,7 +209,7 @@ export const dict = {
"common.saving": "저장 중...",
"common.default": "기본값",
"common.attachment": "첨부 파일",
"prompt.placeholder.shell": "셸 명령어 입력...",
"prompt.placeholder.shell": "셸 명령어 입력... {{example}}",
"prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"',
"prompt.placeholder.simple": "무엇이든 물어보세요...",
"prompt.placeholder.summarizeComments": "댓글 요약…",
+1 -1
View File
@@ -230,7 +230,7 @@ export const dict = {
"common.default": "Standard",
"common.attachment": "vedlegg",
"prompt.placeholder.shell": "Skriv inn shell-kommando...",
"prompt.placeholder.shell": "Skriv inn shell-kommando... {{example}}",
"prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"',
"prompt.placeholder.simple": "Spør om hva som helst...",
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
+1 -1
View File
@@ -211,7 +211,7 @@ export const dict = {
"common.saving": "Zapisywanie...",
"common.default": "Domyślny",
"common.attachment": "załącznik",
"prompt.placeholder.shell": "Wpisz polecenie terminala...",
"prompt.placeholder.shell": "Wpisz polecenie terminala... {{example}}",
"prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"',
"prompt.placeholder.simple": "Zapytaj o cokolwiek...",
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
+1 -1
View File
@@ -227,7 +227,7 @@ export const dict = {
"common.default": "По умолчанию",
"common.attachment": "вложение",
"prompt.placeholder.shell": "Введите команду оболочки...",
"prompt.placeholder.shell": "Введите команду оболочки... {{example}}",
"prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"',
"prompt.placeholder.simple": "Спросите что угодно...",
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
+1 -1
View File
@@ -227,7 +227,7 @@ export const dict = {
"common.default": "ค่าเริ่มต้น",
"common.attachment": "ไฟล์แนบ",
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์... {{example}}",
"prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
"prompt.placeholder.simple": "ถามอะไรก็ได้...",
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
+1 -1
View File
@@ -232,7 +232,7 @@ export const dict = {
"common.default": "Varsayılan",
"common.attachment": "ek",
"prompt.placeholder.shell": "Kabuk komutu girin...",
"prompt.placeholder.shell": "Kabuk komutu girin... {{example}}",
"prompt.placeholder.normal": 'Bir şeyler sorun... "{{example}}"',
"prompt.placeholder.simple": "Bir şeyler sorun...",
"prompt.placeholder.summarizeComments": "Yorumları özetle…",
+1 -1
View File
@@ -249,7 +249,7 @@ export const dict = {
"common.default": "默认",
"common.attachment": "附件",
"prompt.placeholder.shell": "输入 shell 命令...",
"prompt.placeholder.shell": "输入 shell 命令... {{example}}",
"prompt.placeholder.normal": '随便问点什么... "{{example}}"',
"prompt.placeholder.simple": "随便问点什么...",
"prompt.placeholder.summarizeComments": "总结评论…",
+1 -1
View File
@@ -227,7 +227,7 @@ export const dict = {
"common.default": "預設",
"common.attachment": "附件",
"prompt.placeholder.shell": "輸入 shell 命令...",
"prompt.placeholder.shell": "輸入 shell 命令... {{example}}",
"prompt.placeholder.normal": '隨便問點什麼... "{{example}}"',
"prompt.placeholder.simple": "隨便問點什麼...",
"prompt.placeholder.summarizeComments": "摘要評論…",
+2 -3
View File
@@ -244,10 +244,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
}
async function installUpdate() {
if (!platform.update || !platform.restart) return
if (!platform.updateAndRestart) return
await platform
.update()
.then(() => platform.restart!())
.updateAndRestart()
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
+2 -3
View File
@@ -366,7 +366,7 @@ export default function Layout(props: ParentProps) {
const useUpdatePolling = () =>
onMount(() => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return
if (!platform.checkUpdate || !platform.updateAndRestart) return
let toastId: number | undefined
let interval: ReturnType<typeof setInterval> | undefined
@@ -384,8 +384,7 @@ export default function Layout(props: ParentProps) {
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.update!()
await platform.restart!()
await platform.updateAndRestart!()
},
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"scripts": {
@@ -53,9 +53,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
anthropic_version: "bedrock-2023-05-31",
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
}
: {
service_tier: "standard_only",
}),
: {}),
}),
createBinaryStreamDecoder: () => {
if (!isBedrock) return undefined
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.22",
"version": "1.14.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.22",
"version": "1.14.23",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.22",
"version": "1.14.23",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+17 -4
View File
@@ -337,11 +337,16 @@ function setupAutoUpdater() {
})
}
let updateReady = false
let downloadedUpdateVersion: string | undefined
async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false }
updateReady = false
if (downloadedUpdateVersion) {
logger.log("returning cached downloaded update", {
version: downloadedUpdateVersion,
})
return { updateAvailable: true, version: downloadedUpdateVersion }
}
logger.log("checking for updates", {
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
@@ -367,7 +372,7 @@ async function checkUpdate() {
logger.log("update available", { version })
await autoUpdater.downloadUpdate()
logger.log("update download completed", { version })
updateReady = true
downloadedUpdateVersion = version
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
@@ -376,7 +381,15 @@ async function checkUpdate() {
}
async function installUpdate() {
if (!updateReady) return
if (!downloadedUpdateVersion) {
logger.log("install update skipped", {
reason: "no downloaded update ready",
})
return
}
logger.log("installing downloaded update", {
version: downloadedUpdateVersion,
})
killSidecar()
autoUpdater.quitAndInstall()
}
@@ -170,7 +170,7 @@ const createPlatform = (): Platform => {
return window.api.checkUpdate()
},
update: async () => {
updateAndRestart: async () => {
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
if (!config.updaterEnabled) return
await window.api.installUpdate()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"scripts": {
+7 -2
View File
@@ -297,10 +297,15 @@ const createPlatform = (): Platform => {
return { updateAvailable: true, version: next.version }
},
update: async () => {
updateAndRestart: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
const installed = await update
.install()
.then(() => true)
.catch(() => false)
if (!installed) return
await relaunch()
},
restart: async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.22",
"version": "1.14.23",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.22"
version = "1.14.23"
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.14.22/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.23/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.23/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.23/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.14.22/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.23/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.14.22/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.23/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.22",
"version": "1.14.23",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.22",
"version": "1.14.23",
"name": "opencode",
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -409,7 +409,7 @@ Current instance route inventory:
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `next`
- `workspace` - `bridged`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -448,7 +448,7 @@ Recommended near-term sequence:
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [x] port `GET /config` full read endpoint
- [ ] port `workspace` read endpoints
- [x] port `workspace` read endpoints
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
+28 -28
View File
@@ -254,8 +254,8 @@ Working rule for this cluster:
5. Errors and event payloads last
- `NamedError.create(...)` shapes can stay temporarily if converting them to
`Schema.TaggedErrorClass` would force unrelated churn
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using
derived `.zod` until the sync/bus layers are migrated
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can use
derived `.zod` at remaining zod-based HTTP/OpenAPI boundaries
Possible later tightening after the Schema-first migration is stable:
@@ -284,25 +284,25 @@ Each tool declares its parameters via a zod schema. Tools are consumed by
both the in-process runtime and the AI SDK's tool-calling layer, so the
emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/apply_patch.ts`
- [ ] `src/tool/bash.ts`
- [ ] `src/tool/codesearch.ts`
- [ ] `src/tool/edit.ts`
- [ ] `src/tool/glob.ts`
- [ ] `src/tool/grep.ts`
- [ ] `src/tool/invalid.ts`
- [ ] `src/tool/lsp.ts`
- [ ] `src/tool/plan.ts`
- [ ] `src/tool/question.ts`
- [ ] `src/tool/read.ts`
- [ ] `src/tool/registry.ts`
- [ ] `src/tool/skill.ts`
- [ ] `src/tool/task.ts`
- [ ] `src/tool/todo.ts`
- [ ] `src/tool/tool.ts`
- [ ] `src/tool/webfetch.ts`
- [ ] `src/tool/websearch.ts`
- [ ] `src/tool/write.ts`
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`
- [x] `src/tool/invalid.ts`
- [x] `src/tool/lsp.ts`
- [x] `src/tool/plan.ts`
- [x] `src/tool/question.ts`
- [x] `src/tool/read.ts`
- [x] `src/tool/registry.ts`
- [x] `src/tool/skill.ts`
- [x] `src/tool/task.ts`
- [x] `src/tool/todo.ts`
- [x] `src/tool/tool.ts`
- [x] `src/tool/webfetch.ts`
- [x] `src/tool/websearch.ts`
- [x] `src/tool/write.ts`
### HTTP route boundaries
@@ -313,8 +313,8 @@ which means touching them is largely mechanical once the domain side is
done.
- [ ] `src/server/error.ts`
- [ ] `src/server/event.ts`
- [ ] `src/server/projectors.ts`
- [x] `src/server/event.ts`
- [x] `src/server/projectors.ts`
- [ ] `src/server/routes/control/index.ts`
- [ ] `src/server/routes/control/workspace.ts`
- [ ] `src/server/routes/global.ts`
@@ -346,7 +346,7 @@ piecewise.
- [ ] `src/acp/agent.ts`
- [ ] `src/agent/agent.ts`
- [ ] `src/bus/bus-event.ts`
- [x] `src/bus/bus-event.ts`
- [ ] `src/bus/index.ts`
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
@@ -354,9 +354,9 @@ piecewise.
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [ ] `src/control-plane/adaptors/worktree.ts`
- [ ] `src/control-plane/types.ts`
- [ ] `src/control-plane/workspace.ts`
- [x] `src/control-plane/adaptors/worktree.ts`
- [x] `src/control-plane/types.ts`
- [x] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
- [ ] `src/file/ripgrep.ts`
- [ ] `src/file/watcher.ts`
@@ -376,7 +376,7 @@ piecewise.
- [ ] `src/snapshot/index.ts`
- [ ] `src/storage/db.ts`
- [ ] `src/storage/storage.ts`
- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; still stores derived zod `schema`/`properties` on each `Definition` as a compat bridge for `BusEvent` until `bus/bus-event.ts` migrates
- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; `payloads()` still derives zod for the remaining HTTP/OpenAPI boundary
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`
+4 -1
View File
@@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project"
import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
@@ -177,7 +178,9 @@ export function tui(input: {
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
@@ -1,9 +1,11 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import path from "path"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@@ -77,6 +79,7 @@ export function Autocomplete(props: {
agentStyleId: number
promptPartTypeId: () => number
}) {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
@@ -221,6 +224,70 @@ export function Autocomplete(props: {
}
}
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
const urlObj = pathToFileURL(fullPath)
const filename =
lineRange && !item.endsWith("/")
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
: item
if (lineRange && !item.endsWith("/")) {
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}
return {
filename,
url: urlObj.href,
part: {
type: "file" as const,
mime: "text/plain",
filename,
url: urlObj.href,
source: {
type: "file" as const,
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
},
}
}
function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || process.cwd()
const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute)
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
return relative.split(path.sep).join("/")
}
return absolute.split(path.sep).join("/")
}
function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
const item = normalizeMentionPath(input.filePath)
const lineRange = {
startLine: input.lineStart,
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
}
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.keybinds(true)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
}
const [files] = createResource(
() => search(),
async (query) => {
@@ -250,18 +317,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}
const url = urlObj.href
const { filename, url, part } = createFilePart(item, lineRange)
const isDir = item.endsWith("/")
return {
@@ -270,21 +326,7 @@ export function Autocomplete(props: {
isDirectory: isDir,
path: item,
onSelect: () => {
insertPart(filename, {
type: "file",
mime: "text/plain",
filename,
url,
source: {
type: "file",
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
})
insertPart(filename, part)
},
}
}),
@@ -501,6 +543,14 @@ export function Autocomplete(props: {
}
onMount(() => {
const unsubscribeMention = editor.onMention((mention) => {
insertFileMention(mention)
})
onCleanup(() => {
unsubscribeMention()
})
props.ref({
get visible() {
return store.visible
@@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
import * as Clipboard from "../../util/clipboard"
@@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
const editor = useEditorContext()
const route = useRoute()
const project = useProject()
const sync = useSync()
@@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const editorPath = createMemo(() => editor.selection()?.filePath)
const editorSelectionLabel = createMemo(() => {
const selection = editor.selection()?.selection
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
if (!value) return
const filename = path.basename(value)
const file = /^index\.[^./]+$/.test(filename)
? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
: filename
return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
})
const editorFileLabelDisplay = createMemo(() => {
const file = editorFileLabel()
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = editor.selection()
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
if (start.line === end.line && start.character === end.character) {
return `Note: The user opened the file "${editorSelection.filePath}".`
}
if (start.line === end.line) {
return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
}
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
})(),
synthetic: true,
},
]
: []
if (store.mode === "shell") {
void sdk.client.session.shell({
@@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
model: selectedModel,
variant,
parts: [
...editorParts,
{
id: PartID.ascending(),
type: "text",
@@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
@@ -0,0 +1,319 @@
import { readdirSync, readFileSync, statSync } from "node:fs"
import os from "node:os"
import path from "node:path"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import z from "zod"
import { createSimpleContext } from "./helper"
const MCP_PROTOCOL_VERSION = "2025-11-25"
const JsonRpcMessageSchema = z.object({
id: z.union([z.number(), z.string(), z.null()]).optional(),
method: z.string().optional(),
params: z.unknown().optional(),
result: z.unknown().optional(),
error: z
.object({
code: z.number().optional(),
message: z.string().optional(),
})
.optional(),
})
const PositionSchema = z.object({
line: z.number(),
character: z.number(),
})
const EditorSelectionSchema = z.object({
text: z.string(),
filePath: z.string(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
lineEnd: z.number(),
})
const EditorServerInfoSchema = z.object({
protocolVersion: z.string().optional(),
serverInfo: z
.object({
name: z.string().optional(),
version: z.string().optional(),
})
.optional(),
})
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
export type EditorMention = z.infer<typeof EditorMentionSchema>
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
type EditorConnection = {
url: string
authToken?: string
source: string
}
type EditorLockFile = {
port: number
authToken?: string
transport?: string
workspaceFolders: string[]
mtimeMs: number
}
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: () => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
server: EditorServerInfo | undefined
}>({
status: "disabled",
selection: undefined,
server: undefined,
})
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const scheduleReconnect = (delay: number) => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, delay)
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
setStore("status", "disabled")
scheduleReconnect(1000)
return
}
setStore("status", "connecting")
const current = openEditorSocket(connection)
socket = current
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", selection.data)
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 30000)
scheduleReconnect(delay)
})
}
scheduleReconnect(0)
onCleanup(() => {
closed = true
if (reconnect) clearTimeout(reconnect)
socket?.close()
})
})
return {
enabled() {
return Boolean(resolveEditorConnection())
},
connected() {
return store.status === "connected"
},
selection() {
return store.selection
},
onMention(listener: (mention: EditorMention) => void) {
mentionListeners.add(listener)
return () => mentionListeners.delete(listener)
},
server() {
return store.server
},
}
},
})
function parsePort(value: string | undefined) {
if (!value) return
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return
return parsed
}
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
authToken: lock.authToken,
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile() {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
try {
entries = readdirSync(directory)
} catch {
return
}
const cwd = process.cwd()
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
.filter((entry): entry is EditorLockFile => Boolean(entry))
.sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
return locks[0]
}
function readEditorLockFile(filePath: string): EditorLockFile | undefined {
const port = parsePort(path.basename(filePath, ".lock"))
if (!port) return
try {
const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
if (!isRecord(parsed)) return
if (parsed.transport !== undefined && parsed.transport !== "ws") return
return {
port,
authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined,
transport: typeof parsed.transport === "string" ? parsed.transport : undefined,
workspaceFolders: Array.isArray(parsed.workspaceFolders)
? parsed.workspaceFolders.filter((value): value is string => typeof value === "string")
: [],
mtimeMs: statSync(filePath).mtimeMs,
}
} catch {
return
}
}
function scoreEditorLock(lock: EditorLockFile, cwd: string) {
const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
}
function pathContains(parent: string, child: string) {
const relative = path.relative(path.resolve(parent), path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
return new WebSocket(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
} as any)
}
function parseMessage(value: unknown) {
if (typeof value !== "string") return
try {
return JsonRpcMessageSchema.parse(JSON.parse(value))
} catch {
return
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+13
View File
@@ -201,6 +201,19 @@ export const Info = Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
}),
),
tool_output: Schema.optional(
Schema.Struct({
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
).annotate({
description:
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
}),
compaction: Schema.optional(
Schema.Struct({
auto: Schema.optional(Schema.Boolean).annotate({
@@ -1,12 +1,6 @@
import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
@@ -1,13 +1,15 @@
import z from "zod"
import { Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const WorktreeConfig = z.object({
name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
branch: Schema.String,
directory: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
@@ -22,7 +24,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
}
},
async create(info) {
const config = WorktreeConfig.parse(info)
const config = WorktreeConfig.zod.parse(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
@@ -34,11 +36,11 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
)
},
async remove(info) {
const config = WorktreeConfig.parse(info)
const config = WorktreeConfig.zod.parse(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
target(info) {
const config = WorktreeConfig.parse(info)
const config = WorktreeConfig.zod.parse(info)
return {
type: "local",
directory: config.directory,
+21 -10
View File
@@ -1,17 +1,28 @@
import z from "zod"
import { Schema } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod"
import { type DeepMutable, withStatics } from "@/util/schema"
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
name: z.string(),
branch: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID,
type: Schema.String,
name: Schema.String,
branch: Schema.NullOr(Schema.String),
directory: Schema.NullOr(Schema.String),
extra: Schema.NullOr(Schema.Unknown),
projectID: ProjectID,
})
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
.annotate({ identifier: "Workspace" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceAdaptorEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
description: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
export type Target =
| {
@@ -1,4 +1,3 @@
import z from "zod"
import { Schema } from "effect"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
@@ -16,7 +15,7 @@ import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/shared/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { Session } from "@/session"
@@ -26,12 +25,11 @@ import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt } from "@/util/schema"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
@@ -75,15 +73,16 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
}
}
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
extra: Info.shape.extra,
})
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectID,
extra: Info.fields.extra,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const create = fn(CreateInput, async (input) => {
export const create = fn(CreateInput.zod, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
@@ -139,12 +138,13 @@ export const create = fn(CreateInput, async (input) => {
return info
})
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
export const SessionRestoreInput = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
+10 -2
View File
@@ -111,12 +111,13 @@ export type ParsedStreamError =
| {
type: "api_error"
message: string
isRetryable: false
isRetryable: boolean
responseBody: string
}
export function parseStreamError(input: unknown): ParsedStreamError | undefined {
const body = json(input)
const raw = json(input)
const body = typeof raw?.message === "string" ? (json(raw.message) ?? raw) : raw
if (!body) return
const responseBody = JSON.stringify(body)
@@ -150,6 +151,13 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
isRetryable: false,
responseBody,
}
case "server_error":
return {
type: "api_error",
message: typeof body?.error?.message === "string" ? body?.error?.message : "Server error.",
isRetryable: true,
responseBody,
}
}
}
+1
View File
@@ -0,0 +1 @@
README.md
@@ -1,5 +1,5 @@
This is a temporary package used primarily for GitHub Copilot compatibility.
Avoid making changes to these files unless you only want to affect the Copilot provider.
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
Also, this should ONLY be used for the Copilot provider.
Avoid making edits to these files
+19 -17
View File
@@ -185,24 +185,19 @@ function normalizeMessages(
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
[field]: reasoningText,
},
},
}
}
// Include reasoning_content | reasoning_details directly on the message for all assistant messages.
// Always set the field even when empty — some providers (e.g. DeepSeek) may return empty
// reasoning_content which still needs to be sent back in subsequent requests.
return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
[field]: reasoningText,
},
},
}
}
@@ -405,7 +400,10 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
const id = model.id.toLowerCase()
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
if (
id.includes("deepseek") ||
id.includes("deepseek-chat") ||
id.includes("deepseek-reasoner") ||
id.includes("deepseek-r1") ||
id.includes("deepseek-v3") ||
id.includes("minimax") ||
id.includes("glm") ||
id.includes("kimi") ||
@@ -531,7 +529,11 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "venice-ai-sdk-provider":
// https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort
case "@ai-sdk/openai-compatible":
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
if (model.api.id.includes("deepseek-v4")) {
efforts.push("max")
}
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/azure":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure
@@ -3,6 +3,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
@@ -25,15 +26,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(
z.array(
z.object({
type: z.string(),
name: z.string(),
description: z.string(),
}),
),
),
schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))),
},
},
},
@@ -54,7 +47,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace created",
content: {
"application/json": {
schema: resolver(Workspace.Info),
schema: resolver(Workspace.Info.zod),
},
},
},
@@ -63,12 +56,12 @@ export const WorkspaceRoutes = lazy(() =>
}),
validator(
"json",
Workspace.create.schema.omit({
Workspace.CreateInput.zodObject.omit({
projectID: true,
}),
),
async (c) => {
const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
const workspace = await Workspace.create({
projectID: Instance.project.id,
...body,
@@ -87,7 +80,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspaces",
content: {
"application/json": {
schema: resolver(z.array(Workspace.Info)),
schema: resolver(z.array(Workspace.Info.zod)),
},
},
},
@@ -130,7 +123,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace removed",
content: {
"application/json": {
schema: resolver(Workspace.Info.optional()),
schema: resolver(Workspace.Info.zod.optional()),
},
},
},
@@ -140,7 +133,7 @@ export const WorkspaceRoutes = lazy(() =>
validator(
"param",
z.object({
id: Workspace.Info.shape.id,
id: zodObject(Workspace.Info).shape.id,
}),
),
async (c) => {
@@ -170,11 +163,11 @@ export const WorkspaceRoutes = lazy(() =>
...errors(400),
},
}),
validator("param", z.object({ id: Workspace.Info.shape.id })),
validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })),
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
log.info("session restore route requested", {
workspaceID: id,
sessionID: body.sessionID,
@@ -14,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
import { memoMap } from "@/effect/memo-map"
const Query = Schema.Struct({
@@ -112,6 +113,7 @@ const PermissionSecured = PermissionApi.middleware(Authorization)
const ProjectSecured = ProjectApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const ConfigSecured = ConfigApi.middleware(Authorization)
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
@@ -119,6 +121,7 @@ export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
).pipe(
Layer.provide(auth),
Layer.provide(normalize),
@@ -0,0 +1,82 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/workspace"
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: Schema.Array(WorkspaceAdaptorEntry),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
success: Schema.Array(Workspace.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
description: "List all workspaces.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: Schema.Array(Workspace.ConnectionStatus),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.status",
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "workspace",
description: "Experimental HttpApi workspace routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const workspaceHandlers = Layer.unwrap(
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const ctx = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(ctx.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
)
}),
)
+18 -7
View File
@@ -16,6 +16,9 @@ import { GlobalRoutes } from "./routes/global"
import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import { WorkspacePaths } from "./routes/instance/httpapi/workspace"
import { Context } from "effect"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -54,16 +57,24 @@ function create(opts: { cors?: string[] }) {
}
}
const workspaceApp = new Hono()
const workspaceLegacyApp = new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
}
workspaceApp.route("/", workspaceLegacyApp)
return {
app: app
.route("/", ControlPlaneRoutes())
.route(
"/",
new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
)
.route("/", workspaceApp)
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
+9 -8
View File
@@ -416,9 +416,8 @@ export const BashTool = Tool.define(
},
ctx: Tool.Context,
) {
const bytes = Truncate.MAX_BYTES
const lines = Truncate.MAX_LINES
const keep = bytes * 2
const limits = yield* trunc.limits()
const keep = limits.maxBytes * 2
let full = ""
let last = ""
const list: Chunk[] = []
@@ -458,7 +457,7 @@ export const BashTool = Tool.define(
sink?.write(chunk)
} else {
full += chunk
if (Buffer.byteLength(full, "utf-8") > bytes) {
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
return trunc.write(full).pipe(
Effect.andThen((next) =>
Effect.sync(() => {
@@ -525,7 +524,7 @@ export const BashTool = Tool.define(
}
if (aborted) meta.push("User aborted the command")
const raw = list.map((item) => item.text).join("")
const end = tail(raw, lines, bytes)
const end = tail(raw, limits.maxLines, limits.maxBytes)
if (end.cut) cut = true
if (!file && end.cut) {
file = yield* trunc.write(raw)
@@ -566,7 +565,7 @@ export const BashTool = Tool.define(
})
return () =>
Effect.sync(() => {
Effect.gen(function* () {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
@@ -575,13 +574,15 @@ export const BashTool = Tool.define(
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
const limits = yield* trunc.limits()
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
.replaceAll("${maxLines}", String(limits.maxLines))
.replaceAll("${maxBytes}", String(limits.maxBytes)),
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
+20 -4
View File
@@ -1,9 +1,10 @@
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { evaluate } from "@/permission/evaluate"
import { Config } from "../config"
import { Identifier } from "../id/id"
import { Log } from "../util"
import { ToolID } from "./schema"
@@ -38,6 +39,10 @@ export interface Interface {
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
/**
* Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset.
*/
readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
@@ -68,9 +73,20 @@ export const layer = Layer.effect(
return file
})
const limits = Effect.fn("Truncate.limits")(function* () {
const configSvc = yield* Effect.serviceOption(Config.Service)
if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
return {
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
}
})
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const resolved = yield* limits()
const maxLines = options.maxLines ?? resolved.maxLines
const maxBytes = options.maxBytes ?? resolved.maxBytes
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
@@ -135,7 +151,7 @@ export const layer = Layer.effect(
Effect.forkScoped,
)
return Service.of({ cleanup, write, output })
return Service.of({ cleanup, write, output, limits })
}),
)
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
function request(path: string, directory: string) {
return ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${path}`, {
headers: {
"x-opencode-directory": directory,
},
}),
context,
)
}
afterEach(async () => {
await Instance.disposeAll()
await resetDatabase()
})
describe("workspace HttpApi", () => {
test("serves read endpoints", async () => {
await using tmp = await tmpdir({ git: true })
const [adaptors, workspaces, status] = await Promise.all([
request(WorkspacePaths.adaptors, tmp.path),
request(WorkspacePaths.list, tmp.path),
request(WorkspacePaths.status, tmp.path),
])
expect(adaptors.status).toBe(200)
expect(await adaptors.json()).toEqual([
{
type: "worktree",
name: "Worktree",
description: "Create a git worktree",
},
])
expect(workspaces.status).toBe(200)
expect(await workspaces.json()).toEqual([])
expect(status.status).toBe(200)
expect(await status.json()).toEqual([])
})
})
@@ -1075,6 +1075,30 @@ describe("session.message-v2.fromError", () => {
})
})
test("serializes OpenAI response server_error stream chunks as retryable APIError", () => {
const body = {
type: "error",
sequence_number: 2,
error: {
type: "server_error",
code: "server_error",
message:
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_77eccd008d984bf6bf82d1b2c2b68715 in your message.",
param: null,
},
}
const result = MessageV2.fromError({ message: JSON.stringify(body) }, { providerID })
expect(result).toStrictEqual({
name: "APIError",
data: {
message: body.error.message,
isRetryable: true,
responseBody: JSON.stringify(body),
},
})
})
test("detects context overflow from APICallError provider messages", () => {
const cases = [
"prompt is too long: 213462 tokens > 200000 maximum",
@@ -294,4 +294,26 @@ describe("session.message-v2.fromError", () => {
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
expect(result.data.isRetryable).toBe(true)
})
test("converts OpenAI server_error stream chunks to retryable APIError", () => {
const result = MessageV2.fromError(
{
message: JSON.stringify({
type: "error",
sequence_number: 2,
error: {
type: "server_error",
code: "server_error",
message: "An error occurred while processing your request.",
param: null,
},
}),
},
{ providerID: ProviderID.make("openai") },
)
expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.")
})
})
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool"
import { Config } from "../../src/config"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util"
import { Filesystem } from "../../src/util"
@@ -14,6 +15,14 @@ const ROOT = path.resolve(import.meta.dir, "..", "..")
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
const configuredLayer = (cfg: Config.Info) =>
Layer.mergeAll(
Truncate.defaultLayer,
NodeFileSystem.layer,
Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
)
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))
describe("Truncate", () => {
describe("output", () => {
it.live("truncates large json file by bytes", () =>
@@ -94,6 +103,61 @@ describe("Truncate", () => {
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
})
it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const resolved = yield* svc.limits()
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
}),
)
describe("with tool_output config", () => {
const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } })
limitsIt.live("limits() reflects config overrides", () =>
Effect.gen(function* () {
const resolved = yield* (yield* Truncate.Service).limits()
expect(resolved.maxLines).toBe(123)
expect(resolved.maxBytes).toBe(456)
}),
)
// Huge byte budget isolates line truncation. 100 lines against max_lines: 10
// proves the configured line limit is what `output()` enforces.
const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } })
lineIt.live("output() truncates to configured max_lines", () =>
Effect.gen(function* () {
const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* (yield* Truncate.Service).output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
}),
)
// Huge line budget isolates byte truncation.
const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } })
byteIt.live("output() truncates to configured max_bytes", () =>
Effect.gen(function* () {
const content = "a".repeat(1000)
const result = yield* (yield* Truncate.Service).output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
}),
)
const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } })
overrideIt.live("per-call options still override config", () =>
Effect.gen(function* () {
const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
const result = yield* (yield* Truncate.Service).output(content, {
maxLines: 1000,
maxBytes: 1024 * 1024,
})
expect(result.truncated).toBe(false)
}),
)
})
it.live("large single-line file truncates with byte message", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"scripts": {
+13
View File
@@ -1633,6 +1633,19 @@ export type Config = {
*/
url?: string
}
/**
* Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.
*/
tool_output?: {
/**
* Maximum lines of tool output before it is truncated and saved to disk (default: 2000)
*/
max_lines?: number
/**
* Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)
*/
max_bytes?: number
}
compaction?: {
/**
* Enable automatic compaction when context is full (default: true)
+18
View File
@@ -11822,6 +11822,24 @@
}
}
},
"tool_output": {
"description": "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
"type": "object",
"properties": {
"max_lines": {
"description": "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"max_bytes": {
"description": "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
}
},
"compaction": {
"type": "object",
"properties": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.22",
"version": "1.14.23",
"name": "@opencode-ai/shared",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"scripts": {
@@ -5,7 +5,7 @@ const dict: Record<string, string> = {
"prompt.loading": "Loading prompt...",
"prompt.placeholder.normal": "Ask anything...",
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.shell": "Run a shell command...",
"prompt.placeholder.shell": "Run a shell command... {{example}}",
"prompt.placeholder.summarizeComment": "Summarize this comment",
"prompt.placeholder.summarizeComments": "Summarize these comments",
"prompt.action.attachFile": "Attach files",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.22",
"version": "1.14.23",
"type": "module",
"license": "MIT",
"exports": {
+3 -1
View File
@@ -102,6 +102,7 @@ const icons = {
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
@@ -111,7 +112,8 @@ export interface IconProps extends ComponentProps<"svg"> {
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
const viewBox = () => (local.name === "magnifying-glass" ? "0 0 16 16" : "0 0 20 20")
const viewBox = () =>
local.name === "magnifying-glass" || local.name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20"
return (
<div data-component="icon" data-size={local.size || "normal"}>
<svg
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.22",
"version": "1.14.23",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -575,7 +575,7 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص
└ enter
```
4. شغّل الأمر `/models` لاختيار نموذج من DeepSeek مثل _DeepSeek Reasoner_.
4. شغّل الأمر `/models` لاختيار نموذج من DeepSeek مثل _DeepSeek V4 Pro_.
```txt
/models
@@ -580,7 +580,7 @@ Također možete dodati modele kroz svoju opencode konfiguraciju.
└ enter
```
4. Pokrenite naredbu `/models` da odaberete DeepSeek model kao što je _DeepSeek Reasoner_.
4. Pokrenite naredbu `/models` da odaberete DeepSeek model kao što je _DeepSeek V4 Pro_.
```txt
/models
@@ -571,7 +571,7 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W
└ enter
```
4. Kør kommandoen `/models` for at vælge en DeepSeek-model som _DeepSeek Reasoner_.
4. Kør kommandoen `/models` for at vælge en DeepSeek-model som _DeepSeek V4 Pro_.
```txt
/models
@@ -577,7 +577,7 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf
└ enter
```
4. Führen Sie den Befehl `/models` aus, um ein DeepSeek-Modell wie _DeepSeek Reasoner_ auszuwählen.
4. Führen Sie den Befehl `/models` aus, um ein DeepSeek-Modell wie _DeepSeek V4 Pro_ auszuwählen.
```txt
/models
@@ -578,7 +578,7 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers
└ enter
```
4. Ejecute el comando `/models` para seleccionar un modelo de DeepSeek como _DeepSeek Reasoner_.
4. Ejecute el comando `/models` para seleccionar un modelo de DeepSeek como _DeepSeek V4 Pro_.
```txt
/models
@@ -581,7 +581,7 @@ Vous pouvez également ajouter des modèles via votre configuration opencode.
└ enter
```
4. Exécutez la commande `/models` pour sélectionner un modèle DeepSeek tel que _DeepSeek Reasoner_.
4. Exécutez la commande `/models` pour sélectionner un modèle DeepSeek tel que _DeepSeek V4 Pro_.
```txt
/models
@@ -555,7 +555,7 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo
└ enter
```
4. Esegui il comando `/models` per selezionare un modello DeepSeek come _DeepSeek Reasoner_.
4. Esegui il comando `/models` per selezionare un modello DeepSeek come _DeepSeek V4 Pro_.
```txt
/models
@@ -585,7 +585,7 @@ OpenCode 設定を通じてモデルを追加することもできます。
└ enter
```
4. `/models` コマンドを実行して、_DeepSeek Reasoner_ のような DeepSeek モデルを選択します。
4. `/models` コマンドを実行して、_DeepSeek V4 Pro_ のような DeepSeek モデルを選択します。
```txt
/models
@@ -581,7 +581,7 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세
└ enter
```
4. `/models` 명령을 실행하여 DeepSeek 모델(예: _DeepSeek Reasoner_)을 선택하십시오.
4. `/models` 명령을 실행하여 DeepSeek 모델(예: _DeepSeek V4 Pro_)을 선택하십시오.
```txt
/models
@@ -579,7 +579,7 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo
└ enter
```
4. Kjør kommandoen `/models` for å velge en DeepSeek-modell som _DeepSeek Reasoner_.
4. Kjør kommandoen `/models` for å velge en DeepSeek-modell som _DeepSeek V4 Pro_.
```txt
/models
@@ -577,7 +577,7 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers
└ enter
```
4. Uruchom polecenie `/models`, aby wybrać model DeepSeek, np. _DeepSeek Reasoner_.
4. Uruchom polecenie `/models`, aby wybrać model DeepSeek, np. _DeepSeek V4 Pro_.
```txt
/models
+1 -1
View File
@@ -648,7 +648,7 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
└ enter
```
4. Run the `/models` command to select a DeepSeek model like _DeepSeek Reasoner_.
4. Run the `/models` command to select a DeepSeek model like _DeepSeek V4 Pro_.
```txt
/models
@@ -581,7 +581,7 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W
└ enter
```
4. Execute o comando `/models` para selecionar um modelo DeepSeek como _DeepSeek Reasoner_.
4. Execute o comando `/models` para selecionar um modelo DeepSeek como _DeepSeek V4 Pro_.
```txt
/models
@@ -577,7 +577,7 @@ Cloudflare AI Gateway позволяет вам получать доступ к
└ enter
```
4. Запустите команду `/models`, чтобы выбрать модель DeepSeek, например _DeepSeek Reasoner_.
4. Запустите команду `/models`, чтобы выбрать модель DeepSeek, например _DeepSeek V4 Pro_.
```txt
/models
@@ -577,7 +577,7 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม
└ enter
```
4. รันคำสั่ง `/models` เพื่อเลือกโมเดล DeepSeek เช่น _DeepSeek Reasoner_
4. รันคำสั่ง `/models` เพื่อเลือกโมเดล DeepSeek เช่น _DeepSeek V4 Pro_
```txt
/models
@@ -579,7 +579,7 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model
└ enter
```
4. _DeepSeek Reasoner_ gibi bir DeepSeek modeli seçmek için `/models` komutunu çalıştırın.
4. _DeepSeek V4 Pro_ gibi bir DeepSeek modeli seçmek için `/models` komutunu çalıştırın.
```txt
/models
@@ -551,7 +551,7 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic
└ enter
```
4. 执行 `/models` 命令选择 DeepSeek 模型,例如 _DeepSeek Reasoner_。
4. 执行 `/models` 命令选择 DeepSeek 模型,例如 _DeepSeek V4 Pro_。
```txt
/models
@@ -572,7 +572,7 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic
└ enter
```
4. 執行 `/models` 指令選擇 DeepSeek 模型,例如 _DeepSeek Reasoner_。
4. 執行 `/models` 指令選擇 DeepSeek 模型,例如 _DeepSeek V4 Pro_。
```txt
/models
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.22",
"version": "1.14.23",
"publisher": "sst-dev",
"repository": {
"type": "git",