Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 9d6718131e fix: allow deepseek reasoner and chat to have variants since they are now v4 flash 2026-04-26 12:48:02 -05:00
84 changed files with 642 additions and 2013 deletions
+28 -28
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.27",
"version": "1.14.25",
"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.27",
"version": "1.14.25",
"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.27",
"version": "1.14.25",
"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.27",
"version": "1.14.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.27",
"version": "1.14.25",
"bin": {
"opencode": "./bin/opencode",
},
@@ -226,7 +226,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -259,7 +259,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -303,7 +303,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -332,7 +332,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -348,7 +348,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.27",
"version": "1.14.25",
"bin": {
"opencode": "./bin/opencode",
},
@@ -491,7 +491,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -506,8 +506,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
"@opentui/core": ">=0.1.103",
"@opentui/solid": ">=0.1.103",
},
"optionalPeers": [
"@opentui/core",
@@ -526,7 +526,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -541,7 +541,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -576,7 +576,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -625,7 +625,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.27",
"version": "1.14.25",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -685,8 +685,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -1613,21 +1613,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"@opentui/core": ["@opentui/core@0.1.103", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.103", "@opentui/core-darwin-x64": "0.1.103", "@opentui/core-linux-arm64": "0.1.103", "@opentui/core-linux-x64": "0.1.103", "@opentui/core-win32-arm64": "0.1.103", "@opentui/core-win32-x64": "0.1.103", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-PWVv/bDmlk1i6X1f0zXs+jSaTrQ/ByX8wFbP2WinOObTGf//UbcRP4dbWxPXvOyka9QlmRBG/7GbloQSIStyVw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.103", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lxCyedDkcen12IgBtXjkJ7iY66xa7VC4nxRNKCUeLY2ZP9hUE1AsDtbyQzqY+BQadsI/ZME9STzaHDCUFg0TpA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.103", "", { "os": "darwin", "cpu": "x64" }, "sha512-QMYD+zUDGQliJ6m5nuNvA72jtluFeyVMoHkuA5m/Xmed/u8eLfahAKmDj3kY66ntUroPHWevcpbpvd7NCFEoFQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.103", "", { "os": "linux", "cpu": "arm64" }, "sha512-GzOvNr9dN6JaQ9qs7m8E75wLAHwT5CyxqkE6rEr1BO23/d2Ix7e3GYw/JRY5VnTge+eXrfDVbqNtPcQamUNiEA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.103", "", { "os": "linux", "cpu": "x64" }, "sha512-odywllco5zUKNc60uD3JKaCybK64u6BfmpScs4a8Qn89yH/yk23bzWXDRWaGgQdY65L2/VCbcGs1ezA1S/2YTw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.103", "", { "os": "win32", "cpu": "arm64" }, "sha512-tZL5w3Y0JnO7RkIvfuNDAzJn0j6+JIYl6M8DgPM5p8AQt+162S8LmbumzmqQLZl4cEev2eN7/tw72WIk6b+/CQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.103", "", { "os": "win32", "cpu": "x64" }, "sha512-wqnibt/OE5ldSzVPxEbriA0TjI2B11CJl4uJoLxTZ47KDx7tAFIMdhBf9IRAGNCSbcDuZ8ZGEFhV+SLaftkrlw=="],
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"@opentui/solid": ["@opentui/solid@0.1.103", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.103", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-L28WFBs17Z5JXkJhPagLy8tUamkttDaaQDdb2KEO01IQ9r81yLBRpYqD/lHp6UoSISXgwQVDQ9yNtxwR1BQZvQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-zQOuZkuOLm9xV5YzfcQiWSzvussXVw/dRPw4HhuYsdc=",
"aarch64-linux": "sha256-J8Ak9rCCkKlBj0KhdUOklglpR3ntRP2VjtdmEJox6Ro=",
"aarch64-darwin": "sha256-r4F6S9CfGR6qxSaSqu9AIjWCEKd/PuT1Wprcbzk0IwY=",
"x86_64-darwin": "sha256-I/ztb7VnLbJYzAxf2egN0+YsJcP5yix3tIoHtiuRUXE="
"x86_64-linux": "sha256-I5qF+zaPXIXlPWjI54XlsZ42E5EgUWbo74/ClaoJnNY=",
"aarch64-linux": "sha256-2yqFliYylXy3Lnf95nO/enefCrmP8GOfqKjnstdaMu4=",
"aarch64-darwin": "sha256-/Rk2Q6bPxOzD24HPQOe/EXlcEb7eGLGHi3P3iSqHaTU=",
"x86_64-darwin": "sha256-sKyoY9Z7858S0KG/mDhuGZLR2P7AdK+zy0xUVEJ3bt0="
}
}
+2 -2
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.27",
"version": "1.14.25",
"description": "",
"type": "module",
"exports": {
+65 -122
View File
@@ -11,9 +11,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import {
monoDefault,
monoFontFamily,
@@ -42,18 +40,6 @@ type ThemeOption = {
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
type ShellSelectOption = {
id: string
value: string
label: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
@@ -89,6 +75,10 @@ export const SettingsGeneral: Component = () => {
const params = useParams()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
@@ -175,70 +165,6 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const globalSync = useGlobalSync()
const globalSdk = useGlobalSDK()
const [shells] = createResource(
() =>
globalSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = globalSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
@@ -317,27 +243,6 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
globalSync.updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -746,32 +651,70 @@ export const SettingsGeneral: Component = () => {
<SoundsSection />
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
>
<div data-action="settings-wsl">
<Switch
checked={enabled() ?? false}
disabled={enabledResource.state === "pending"}
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
}}
</Show>*/}
<UpdatesSection />
<Show when={linux()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
{(_) => {
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
<SettingsList>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
const onChange = (checked: boolean) =>
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={value() === "wayland"} onChange={onChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
)
}}
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
@@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
input.setGlobalStore("config", x.data!)
}),
),
]
@@ -245,7 +245,7 @@ export async function bootstrapDirectory(input: {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false }))
input.setStore("config", input.global.config)
}
if (loading || input.store.provider.all.length === 0) {
input.setStore("provider_ready", false)
@@ -265,8 +265,7 @@ export async function bootstrapDirectory(input: {
input.queryClient.ensureQueryData(
loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
!seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
-5
View File
@@ -728,11 +728,6 @@ export const dict = {
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal Shell",
"settings.general.row.shell.description":
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
"settings.general.row.shell.autoDefault": "Auto (Default)",
"settings.general.row.shell.terminalOnly": "terminal only",
"settings.general.row.appearance.title": "Appearance",
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
"settings.general.row.colorScheme.title": "Color scheme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"scripts": {
@@ -1,10 +0,0 @@
import type { APIEvent } from "@solidjs/start/server"
import { getHandler, optionsHandler } from "../../util/modelsHandler"
export async function OPTIONS(_input: APIEvent) {
return optionsHandler()
}
export async function GET(input: APIEvent) {
return getHandler({ modelList: "lite" })
}
@@ -1,36 +0,0 @@
import { ZenData } from "@opencode-ai/console-core/model.js"
export async function optionsHandler() {
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
})
}
export async function getHandler(opts: { modelList: "lite" | "full"; disabledModels?: string[] }) {
const zenData = ZenData.list(opts.modelList)
return new Response(
JSON.stringify({
object: "list",
data: Object.entries(zenData.models)
.filter(([id]) => !opts.disabledModels?.includes(id))
.filter(([id]) => !id.startsWith("alpha-"))
.map(([id, _model]) => ({
id,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "opencode",
})),
}),
{
headers: {
"Content-Type": "application/json",
},
},
)
}
@@ -3,29 +3,59 @@ import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/ind
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { optionsHandler, getHandler } from "~/routes/zen/util/modelsHandler"
import { ZenData } from "@opencode-ai/console-core/model.js"
export async function OPTIONS(_input: APIEvent) {
return optionsHandler()
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
})
}
export async function GET(input: APIEvent) {
const disabledModels = await (() => {
const zenData = ZenData.list("full")
const disabledModels = await authenticate()
return new Response(
JSON.stringify({
object: "list",
data: Object.entries(zenData.models)
.filter(([id]) => !disabledModels.includes(id))
.filter(([id]) => !id.startsWith("alpha-"))
.map(([id, _model]) => ({
id,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "opencode",
})),
}),
{
headers: {
"Content-Type": "application/json",
},
},
)
async function authenticate() {
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
if (!apiKey) return []
return Database.use((tx) =>
const disabledModels = await Database.use((tx) =>
tx
.select({
model: ModelTable.model,
})
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows.map((row) => row.model)),
)
})()
return getHandler({ modelList: "full", disabledModels })
return disabledModels
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.27",
"version": "1.14.25",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.27",
"version": "1.14.25",
"$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.27",
"version": "1.14.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.27",
"version": "1.14.25",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.27",
"version": "1.14.25",
"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.27"
version = "1.14.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.14.27/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.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.14.27/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.27/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.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.14.27/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.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.14.27/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.27",
"version": "1.14.25",
"$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.27",
"version": "1.14.25",
"name": "opencode",
"type": "module",
"license": "MIT",
+23 -23
View File
@@ -184,7 +184,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
| `sync` | `bridged` | start/replay/history |
| `event` | `bridged` | SSE via raw Effect HTTP |
| `event` | `special` | SSE |
| `pty` | `special` | websocket |
| `tui` | `special` | UI bridge |
@@ -316,32 +316,32 @@ This checklist tracks bridge parity only. Checked routes are available through t
### Event Routes
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
- [ ] `GET /event` - SSE event stream; replace with raw Effect HTTP, not `HttpApi`.
### PTY Routes
- [x] `GET /pty` - list PTY sessions.
- [x] `POST /pty` - create PTY session.
- [x] `GET /pty/:ptyID` - get PTY session.
- [x] `PUT /pty/:ptyID` - update PTY session.
- [x] `DELETE /pty/:ptyID` - remove PTY session.
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
- [ ] `GET /pty` - list PTY sessions.
- [ ] `POST /pty` - create PTY session.
- [ ] `GET /pty/:ptyID` - get PTY session.
- [ ] `PUT /pty/:ptyID` - update PTY session.
- [ ] `DELETE /pty/:ptyID` - remove PTY session.
- [ ] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
### TUI Routes
- [x] `POST /tui/append-prompt` - append prompt.
- [x] `POST /tui/open-help` - open help.
- [x] `POST /tui/open-sessions` - open sessions.
- [x] `POST /tui/open-themes` - open themes.
- [x] `POST /tui/open-models` - open models.
- [x] `POST /tui/submit-prompt` - submit prompt.
- [x] `POST /tui/clear-prompt` - clear prompt.
- [x] `POST /tui/execute-command` - execute command.
- [x] `POST /tui/show-toast` - show toast.
- [x] `POST /tui/publish` - publish TUI event.
- [x] `POST /tui/select-session` - select session.
- [x] `GET /tui/control/next` - get next TUI request.
- [x] `POST /tui/control/response` - submit TUI control response.
- [ ] `POST /tui/append-prompt` - append prompt.
- [ ] `POST /tui/open-help` - open help.
- [ ] `POST /tui/open-sessions` - open sessions.
- [ ] `POST /tui/open-themes` - open themes.
- [ ] `POST /tui/open-models` - open models.
- [ ] `POST /tui/submit-prompt` - submit prompt.
- [ ] `POST /tui/clear-prompt` - clear prompt.
- [ ] `POST /tui/execute-command` - execute command.
- [ ] `POST /tui/show-toast` - show toast.
- [ ] `POST /tui/publish` - publish TUI event.
- [ ] `POST /tui/select-session` - select session.
- [ ] `GET /tui/control/next` - get next TUI request.
- [ ] `POST /tui/control/response` - submit TUI control response.
## Remaining PR Plan
@@ -358,8 +358,8 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
+1 -2
View File
@@ -29,8 +29,7 @@ import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { useConnected } from "@tui/component/use-connected"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
@@ -8,7 +8,13 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
export function useConnected() {
const sync = useSync()
return createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
}
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
@@ -14,7 +14,6 @@ import { useKeyboard } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { useConnected } from "./use-connected"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -31,7 +30,6 @@ export function createDialogProviderOptions() {
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const onboarded = useConnected()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
@@ -51,7 +49,7 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected && onboarded() ? <text fg={theme.success}></text> : undefined,
gutter: connected ? <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return
@@ -6,7 +6,8 @@ import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error"
import { errorData, errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@@ -16,6 +17,8 @@ type Adaptor = {
description: string
}
const log = Log.Default.clone().tag("service", "tui-workspace")
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
@@ -34,9 +37,18 @@ export async function openWorkspaceSession(input: {
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
})
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
@@ -44,11 +56,24 @@ export async function openWorkspaceSession(input: {
})
return
}
log.info("workspace session create response", {
workspaceID: input.workspaceID,
status: result.response?.status,
sessionID: result.data?.id,
})
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
log.warn("workspace session create retrying after server error", {
workspaceID: input.workspaceID,
status: result.response.status,
})
await sleep(1000)
continue
}
if (!result.data) {
log.error("workspace session create returned no data", {
workspaceID: input.workspaceID,
status: result.response?.status,
})
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
@@ -60,6 +85,10 @@ export async function openWorkspaceSession(input: {
type: "session",
sessionID: result.data.id,
})
log.info("workspace session create complete", {
workspaceID: input.workspaceID,
sessionID: result.data.id,
})
input.dialog.clear()
return
}
@@ -75,10 +104,27 @@ export async function restoreWorkspaceSession(input: {
sessionID: string
done?: () => void
}) {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch(() => undefined)
.catch((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
if (!result?.data) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result?.response?.status,
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
@@ -86,11 +132,33 @@ export async function restoreWorkspaceSession(input: {
return
}
log.info("session restore response", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result.response?.status,
total: result.data.total,
})
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
})
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total: result.data.total,
})
input.toast.show({
message: "Session restored into the new workspace",
@@ -162,26 +230,47 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => {
if (creating()) return
setCreating(type)
log.info("workspace create requested", {
type,
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
log.error("workspace create request failed", {
type,
error: errorData(err),
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
log.error("workspace create failed", {
type,
status: result?.response.status,
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("workspace create response", {
type,
workspaceID: workspace.id,
status: result.response?.status,
})
await project.workspace.sync()
log.info("workspace create synced", {
type,
workspaceID: workspace.id,
})
await props.onSelect(workspace.id)
setCreating(undefined)
}
@@ -1,9 +0,0 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
export function useConnected() {
const sync = useSync()
return createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
}
@@ -278,16 +278,12 @@ function resolveEditorLockFile() {
}
const cwd = process.cwd()
// longest workspace folder that contains cwd; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
.filter((entry): entry is EditorLockFile => Boolean(entry))
.filter((entry) => bestMatchLength(entry) > 0)
// prefer locks with longer matching workspace folders, then more recent ones
.sort((left, right) => bestMatchLength(right) - bestMatchLength(left) || right.mtimeMs - left.mtimeMs)
.sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
return locks[0]
}
@@ -314,6 +310,11 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
}
}
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 editorSelectionKey(selection: EditorSelection | undefined) {
if (!selection) return ""
return [
@@ -326,10 +327,9 @@ function editorSelectionKey(selection: EditorSelection | undefined) {
].join("\0")
}
function pathContainsLength(parent: string, child: string) {
const resolved = path.resolve(parent)
const relative = path.relative(resolved, path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
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) {
+2 -6
View File
@@ -1,8 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
import { Schema } from "effect"
export const TuiEvent = {
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
@@ -38,9 +36,7 @@ export const TuiEvent = {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
duration: Schema.optional(Schema.Number).annotate({ description: "Duration in milliseconds" }),
}),
),
SessionSelect: BusEvent.define(
@@ -2,7 +2,7 @@ import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useSync } from "../../context/sync"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { useConnected } from "../../component/dialog-model"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
@@ -5,13 +5,10 @@ import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import { Schema } from "effect"
import { TuiEvent } from "../event"
import { type TuiEvent } from "../event"
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.properties>
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties)
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
@@ -58,13 +55,13 @@ function init() {
let timeoutHandle: NodeJS.Timeout | null = null
const toast = {
show(options: ToastInput) {
const toastOptions = decodeToastOptions(options)
setStore("currentToast", toastOptions)
show(options: ToastOptions) {
const { duration, ...currentToast } = options
setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, toastOptions.duration).unref()
}, duration).unref()
},
error: (err: any) => {
if (err instanceof Error)
@@ -1,4 +1,5 @@
export * as Editor from "./editor"
export * as Selection from "./selection"
export * as Sound from "./sound"
export * as Terminal from "./terminal"
export * as Clipboard from "./clipboard"
@@ -0,0 +1,96 @@
import { RGBA } from "@opentui/core"
export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
*
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
return new Promise((resolve) => {
let background: RGBA | null = null
let foreground: RGBA | null = null
const paletteColors: RGBA[] = []
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
}
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
// Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
// Query background (OSC 11)
process.stdout.write("\x1b]11;?\x07")
// Query foreground (OSC 10)
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
}
timeout = setTimeout(() => {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}, 1000)
})
}
+6 -14
View File
@@ -98,9 +98,6 @@ export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.optional(Schema.String).annotate({
description: "Default shell to use for terminal and bash tool",
}),
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
server: Schema.optional(ConfigServer.Server).annotate({
description: "Server configuration for opencode serve and web commands",
@@ -313,7 +310,10 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
return applyEdits(input, edits)
}
return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
return Object.entries(patch).reduce((result, [key, value]) => {
if (value === undefined) return result
return patchJsonc(result, value, [...path, key])
}, input)
}
function writable(info: Info) {
@@ -321,13 +321,6 @@ function writable(info: Info) {
return next
}
function writableGlobal(info: Info) {
const next = writable(info)
// When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key
if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
return next
}
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
@@ -756,16 +749,15 @@ export const layer = Layer.effect(
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const patch = writableGlobal(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), patch)
const merged = mergeDeep(writable(existing), writable(config))
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, patch)
const updated = patchJsonc(before, writable(config))
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
+142 -139
View File
@@ -132,7 +132,6 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
// Use the package manager's resolver so registries, mirrors, auth, proxies, and dist-tags match upgrade behavior.
const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) {
const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"]
const result = yield* run([method, ...args])
@@ -174,159 +173,163 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.orDie,
)
const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
return {
version: InstallationVersion,
latest: yield* result.latest(),
const methodImpl = Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}),
method: Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
}
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
return "unknown" as Method
})
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* methodImpl())
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
return "unknown" as Method
}),
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* result.method())
if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie),
upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let upgradeResult: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
upgradeResult = yield* upgradeCurl(target)
break
case "npm":
upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
upgradeResult = tap
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie)
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
result = yield* upgradeCurl(target)
break
case "npm":
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
result = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
result = pull
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
upgradeResult = pull
break
}
}
}
upgradeResult = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
result = yield* run(["brew", "upgrade", formula], { env })
break
}
if (!upgradeResult || upgradeResult.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: upgradeResult.stdout,
stderr: upgradeResult.stderr,
})
yield* text([process.execPath, "--version"])
}),
}
case "choco":
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: result.stdout,
stderr: result.stderr,
})
yield* text([process.execPath, "--version"])
})
return Service.of(result)
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
version: InstallationVersion,
latest: yield* latestImpl(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
}),
)
@@ -422,8 +422,6 @@ 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-chat") ||
id.includes("deepseek-reasoner") ||
id.includes("deepseek-r1") ||
id.includes("deepseek-v3") ||
id.includes("minimax") ||
+7 -14
View File
@@ -1,17 +1,17 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Config } from "@/config"
import { InstanceState, EffectBridge } from "@/effect"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Plugin } from "@/plugin"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
import { Shell } from "@/shell/shell"
import type { Proc } from "#pty"
import { Log } from "../util"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { EffectBridge } from "@/effect"
const log = Log.create({ service: "pty" })
@@ -117,10 +117,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pt
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const plugin = yield* Plugin.Service
function teardown(session: Active) {
try {
session.process.kill()
@@ -176,9 +174,8 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const id = PtyID.ascending()
const command = input.command || Shell.preferred(cfg.shell)
const command = input.command || Shell.preferred()
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
@@ -363,10 +360,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Config.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
export * as Pty from "."
+1 -1
View File
@@ -101,7 +101,7 @@ const app = (upgrade: UpgradeWebSocket) =>
}),
)
const log = Log.create({ service: "server-proxy" })
const log = Log.Default.clone().tag("service", "server-proxy")
export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
if (!Workspace.isSyncing(workspaceID)) {
@@ -1,46 +0,0 @@
import { Bus } from "@/bus"
import { Log } from "@/util"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
const log = Log.create({ service: "server" })
export const EventPaths = {
event: "/event",
} as const
function eventData(data: unknown) {
return `data: ${JSON.stringify(data)}\n\n`
}
export const eventRoute = HttpRouter.add(
"GET",
EventPaths.event,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}).pipe(Effect.provide(Bus.layer)),
)
@@ -1,205 +0,0 @@
import { EffectBridge } from "@/effect"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { Authorization } from "./auth"
const root = "/pty"
const Params = Schema.Struct({
ptyID: PtyID,
})
const CursorQuery = Schema.Struct({
cursor: Schema.optional(Schema.String),
})
export const PtyPaths = {
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("list", PtyPaths.list, {
success: Schema.Array(Pty.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
payload: Pty.CreateInput,
success: Pty.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "pty",
description: "Experimental HttpApi PTY routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const ptyHandlers = Layer.unwrap(
Effect.gen(function* () {
const pty = yield* Pty.Service
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
handlers
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove),
)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
)
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -1,31 +1,25 @@
import { Effect, Layer, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { Bus } from "@/bus"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Pty } from "@/pty"
import { Session } from "@/session"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { eventRoute } from "./event"
import { FileApi, fileHandlers } from "./file"
import { ExperimentalApi, experimentalHandlers } from "./experimental"
import { InstanceApi, instanceHandlers } from "./instance"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { SessionApi, sessionHandlers } from "./session"
import { SyncApi, syncHandlers } from "./sync"
import { TuiApi, tuiHandlers } from "./tui"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -72,25 +66,17 @@ const instance = HttpRouter.middleware()(
).layer
export const routes = Layer.mergeAll(
eventRoute,
ptyConnectRoute,
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)),
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
HttpApiBuilder.layer(TuiApi).pipe(
Layer.provide(tuiHandlers),
Layer.provide(Session.defaultLayer),
Layer.provide(Bus.layer),
),
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
).pipe(
Layer.provide(authorizationLayer),
@@ -1,286 +0,0 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../tui"
import { Authorization } from "./auth"
const root = "/tui"
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
const TuiRequestPayload = Schema.Struct({
path: Schema.String,
body: Schema.Unknown,
}).annotate({ identifier: "TuiRequest" })
const TuiPublishPayload = Schema.Union([
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
]).annotate({ identifier: "TuiEventInput" })
const commandAliases = {
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",
} as const
export const TuiPaths = {
appendPrompt: `${root}/append-prompt`,
openHelp: `${root}/open-help`,
openSessions: `${root}/open-sessions`,
openThemes: `${root}/open-themes`,
openModels: `${root}/open-models`,
submitPrompt: `${root}/submit-prompt`,
clearPrompt: `${root}/clear-prompt`,
executeCommand: `${root}/execute-command`,
showToast: `${root}/show-toast`,
publish: `${root}/publish`,
selectSession: `${root}/select-session`,
controlNext: `${root}/control/next`,
controlResponse: `${root}/control/response`,
} as const
export const TuiApi = HttpApi.make("tui")
.add(
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
summary: "Append TUI prompt",
description: "Append prompt to the TUI.",
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openHelp",
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openSessions",
summary: "Open sessions dialog",
description: "Open the session dialog.",
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openThemes",
summary: "Open themes dialog",
description: "Open the theme dialog.",
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openModels",
summary: "Open models dialog",
description: "Open the model dialog.",
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.submitPrompt",
summary: "Submit TUI prompt",
description: "Submit the prompt.",
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.clearPrompt",
summary: "Clear TUI prompt",
description: "Clear the prompt.",
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
summary: "Execute TUI command",
description: "Execute a TUI command.",
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
payload: TuiEvent.ToastShow.properties,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.showToast",
summary: "Show TUI toast",
description: "Show a toast notification in the TUI.",
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
summary: "Publish TUI event",
description: "Publish a TUI event.",
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: Schema.Boolean,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.next",
summary: "Get next TUI request",
description: "Retrieve the next TUI request from the queue for processing.",
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
payload: Schema.Unknown,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.response",
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const tuiHandlers = Layer.unwrap(
Effect.gen(function* () {
const bus = yield* Bus.Service
const session = yield* Session.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
yield* session
.get(ctx.payload.sessionID)
.pipe(Effect.catchCause(() => Effect.fail(new HttpApiError.NotFound({}))))
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse),
)
}),
)
@@ -16,15 +16,12 @@ import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { PtyPaths } from "./httpapi/pty"
import { EventPaths } from "./httpapi/event"
import { ExperimentalPaths } from "./httpapi/experimental"
import { FilePaths } from "./httpapi/file"
import { InstancePaths } from "./httpapi/instance"
import { McpPaths } from "./httpapi/mcp"
import { SessionPaths } from "./httpapi/session"
import { SyncPaths } from "./httpapi/sync"
import { TuiPaths } from "./httpapi/tui"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
import { PtyRoutes } from "./pty"
@@ -44,7 +41,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
app.get("/question", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
@@ -98,12 +94,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
@@ -131,19 +121,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
}
return app
@@ -6,41 +6,14 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { NotFoundError } from "@/storage"
import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
const ShellItem = z.object({
path: z.string(),
name: z.string(),
acceptable: z.boolean(),
})
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
.get(
"/shells",
describeRoute({
summary: "List available shells",
description: "Get a list of available shells on the system.",
operationId: "pty.shells",
responses: {
200: {
description: "List of shells",
content: {
"application/json": {
schema: resolver(z.array(ShellItem)),
},
},
},
},
}),
async (c) => {
return c.json(await Shell.list())
},
)
.get(
"/",
describeRoute({
@@ -12,23 +12,15 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
export const TuiRequest = z.object({
const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
export type TuiRequest = z.infer<typeof TuiRequest>
type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<unknown>()
export function nextTuiRequest() {
return request.next()
}
export function submitTuiResponse(body: unknown) {
response.push(body)
}
const response = new AsyncQueue<any>()
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
@@ -58,7 +50,7 @@ const TuiControlRoutes = new Hono()
},
}),
async (c) => {
const req = await nextTuiRequest()
const req = await request.next()
return c.json(req)
},
)
@@ -82,7 +74,7 @@ const TuiControlRoutes = new Hono()
validator("json", z.any()),
async (c) => {
const body = c.req.valid("json")
submitTuiResponse(body)
response.push(body)
return c.json(true)
},
)
+45 -8
View File
@@ -31,7 +31,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Stream from "effect/Stream"
import { Command } from "../command"
import { pathToFileURL, fileURLToPath } from "url"
import { Config, ConfigMarkdown } from "../config"
import { ConfigMarkdown } from "../config"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/core/util/error"
import { SessionProcessor } from "./processor"
@@ -92,7 +92,6 @@ export const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service
const plugin = yield* Plugin.Service
const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service
@@ -784,10 +783,49 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
yield* sessions.updatePart(part)
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const sh = Shell.preferred()
const shellName = (
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
).toLowerCase()
const cwd = ctx.directory
const args = Shell.args(sh, input.command, cwd)
const invocations: Record<string, { args: string[] }> = {
nu: { args: ["-c", input.command] },
fish: { args: ["-c", input.command] },
zsh: {
args: [
"-l",
"-c",
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(input.command)}
`,
"opencode",
cwd,
],
},
bash: {
args: [
"-l",
"-c",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(input.command)}
`,
"opencode",
cwd,
],
},
cmd: { args: ["/c", input.command] },
powershell: { args: ["-NoProfile", "-Command", input.command] },
pwsh: { args: ["-NoProfile", "-Command", input.command] },
"": { args: ["-c", input.command] },
}
const args = (invocations[shellName] ?? invocations[""]).args
const shellEnv = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
@@ -804,6 +842,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let output = ""
let aborted = false
const finish = Effect.uninterruptible(
Effect.gen(function* () {
if (aborted) {
@@ -1549,8 +1588,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const sh = Shell.preferred()
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
@@ -1651,7 +1689,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
+20 -125
View File
@@ -7,23 +7,10 @@ import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
fish: { deny: true, login: true },
ksh: { login: true, posix: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
}
export type Item = {
path: string
name: string
acceptable: boolean
}
const BLACKLIST = new Set(["fish", "nu"])
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
@@ -63,53 +50,22 @@ function full(file: string) {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
return which(shell) || shell
}
function meta(file: string) {
return META[name(file)]
}
function ok(file: string) {
return meta(file)?.deny !== true
}
function rooted(file: string) {
return path.isAbsolute(Filesystem.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (Filesystem.stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
}
function win() {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
),
)
}
async function unix() {
const text = await Filesystem.readText("/etc/shells").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
function pick() {
const pwsh = which("pwsh.exe")
if (pwsh) return pwsh
const powershell = which("powershell.exe")
if (powershell) return powershell
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
if (process.platform === "win32") {
const shell = pick()
if (shell) return shell
}
if (process.platform === "win32") return win()[0]!
return fallback()
}
@@ -123,6 +79,11 @@ export function gitbash() {
}
function fallback() {
if (process.platform === "win32") {
const file = gitbash()
if (file) return file
return process.env.COMSPEC || "cmd.exe"
}
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
if (bash) return bash
@@ -135,81 +96,15 @@ export function name(file: string) {
}
export function login(file: string) {
return meta(file)?.login === true
return LOGIN.has(name(file))
}
export function posix(file: string) {
return meta(file)?.posix === true
return POSIX.has(name(file))
}
export function ps(file: string) {
return meta(file)?.ps === true
}
export const preferred = lazy(() => select(process.env.SHELL))
function info(file: string): Item {
const item = full(file)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
acceptable: ok(item),
}
}
export function args(file: string, command: string, cwd: string) {
const n = name(file)
if (n === "nu" || n === "fish") return ["-c", command]
if (n === "zsh") {
return [
"-l",
"-c",
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "bash") {
return [
"-l",
"-c",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "cmd") return ["/c", command]
if (ps(file)) return ["-NoProfile", "-Command", command]
return ["-c", command]
}
const defaultPreferred = lazy(() => select(process.env.SHELL))
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
return defaultPreferred()
}
preferred.reset = () => defaultPreferred.reset()
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
return defaultAcceptable()
}
acceptable.reset = () => defaultAcceptable.reset()
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
export * as Shell from "./shell"
+9 -8
View File
@@ -11,7 +11,6 @@ import { Language, type Node } from "web-tree-sitter"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { fileURLToPath } from "url"
import { Config } from "@/config"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Shell } from "@/shell/shell"
@@ -25,6 +24,7 @@ import { InstanceState } from "@/effect"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
const PS = new Set(["powershell", "pwsh"])
const CWD = new Set(["cd", "push-location", "set-location"])
const FILES = new Set([
...CWD,
@@ -278,8 +278,8 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
})
})
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) {
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
@@ -296,6 +296,7 @@ function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv
detached: process.platform !== "win32",
})
}
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -327,7 +328,6 @@ const parser = lazy(async () => {
export const BashTool = Tool.define(
"bash",
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const trunc = yield* Truncate.Service
@@ -408,6 +408,7 @@ export const BashTool = Tool.define(
const run = Effect.fn("BashTool.run")(function* (
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
@@ -437,7 +438,7 @@ export const BashTool = Tool.define(
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
@@ -566,8 +567,7 @@ export const BashTool = Tool.define(
return () =>
Effect.gen(function* () {
const cfg = yield* config.get()
const shell = Shell.acceptable(cfg.shell)
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
@@ -595,7 +595,7 @@ export const BashTool = Tool.define(
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = Shell.ps(shell)
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
@@ -604,6 +604,7 @@ export const BashTool = Tool.define(
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
@@ -55,8 +55,6 @@ const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
const listDirs = () =>
@@ -144,106 +142,6 @@ test("loads JSON config file", async () => {
})
})
test("loads shell config field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
shell: "bash",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.shell).toBe("bash")
},
})
})
test("updates config and preserves empty shell sentinel", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(
dir,
{
$schema: "https://opencode.ai/config.json",
shell: "bash",
},
"config.json",
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await save({ shell: "" })
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.shell).toBe("")
},
})
})
test("updates global config and omits empty shell key in json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
shell: "bash",
})
},
})
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
try {
await saveGlobal({ shell: "" })
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json"))
expect("shell" in writtenConfig).toBe(false)
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
test("updates global config and omits empty shell key in jsonc", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
shell: "bash",
model: "test/model",
}),
)
},
})
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
try {
await saveGlobal({ shell: "" })
const file = path.join(tmp.path, "opencode.jsonc")
const writtenConfig = await Filesystem.readText(file)
const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file)
expect(writtenConfig).not.toContain('"shell"')
expect(parsed.shell).toBeUndefined()
expect(parsed.model).toBe("test/model")
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
test("loads formatter boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -67,38 +67,3 @@ describe("pty shell args", () => {
)
}
})
describe("pty configured shell", () => {
test(
"uses configured shell for default PTY command",
async () => {
const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash")
if (!configured) return
await using dir = await tmpdir({
config: { shell: Shell.name(configured) },
})
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* pty.create({ title: "configured" })
try {
if (process.platform === "win32") {
expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
} else {
expect(info.command).toBe(configured)
}
expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"])
} finally {
yield* pty.remove(info.id)
}
}),
),
})
},
{ timeout: 30000 },
)
})
@@ -1,50 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return InstanceRoutes(websocket)
}
async function readFirstChunk(response: Response) {
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
const result = await Promise.race([
reader.read(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timed out waiting for event")), 5_000)),
])
await reader.cancel()
return new TextDecoder().decode(result.value)
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("event HttpApi bridge", () => {
test("serves event stream through experimental Effect route", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/event-stream")
expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
expect(response.headers.get("x-accel-buffering")).toBe("no")
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
})
})
@@ -1,74 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const testPty = process.platform === "win32" ? test.skip : test
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return InstanceRoutes(websocket)
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("pty HttpApi bridge", () => {
testPty("serves PTY JSON routes through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const list = await app().request(PtyPaths.list, { headers })
expect(list.status).toBe(200)
expect(await list.json()).toEqual([])
const created = await app().request(PtyPaths.create, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }),
})
expect(created.status).toBe(200)
const info = await created.json()
try {
expect(info).toMatchObject({ title: "demo", command: "/usr/bin/env", status: "running" })
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
expect(found.status).toBe(200)
expect(await found.json()).toMatchObject({ id: info.id, title: "demo" })
const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
method: "PUT",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }),
})
expect(updated.status).toBe(200)
expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" })
} finally {
await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
}
const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
expect(missing.status).toBe(404)
})
test("returns 404 for missing PTY websocket before upgrade", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
})
@@ -1,79 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
import { callTui } from "../../src/server/routes/instance/tui"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return InstanceRoutes(websocket)
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
const response = await app().request(path, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("tui HttpApi bridge", () => {
test("serves TUI command and event routes through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" })
await expectTrue(TuiPaths.openHelp, headers)
await expectTrue(TuiPaths.openSessions, headers)
await expectTrue(TuiPaths.openThemes, headers)
await expectTrue(TuiPaths.openModels, headers)
await expectTrue(TuiPaths.submitPrompt, headers)
await expectTrue(TuiPaths.clearPrompt, headers)
await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" })
await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" })
await expectTrue(TuiPaths.publish, headers, {
type: "tui.prompt.append",
properties: { text: "from publish" },
})
const missing = await app().request(TuiPaths.selectSession, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ sessionID: SessionID.descending() }),
})
expect(missing.status).toBe(404)
})
test("serves TUI control queue through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)
const headers = { "x-opencode-directory": tmp.path }
const next = await app().request(TuiPaths.controlNext, { headers })
expect(next.status).toBe(200)
expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } })
await expectTrue(TuiPaths.controlResponse, headers, { ok: true })
expect(await pending).toEqual({ ok: true })
})
})
@@ -316,11 +316,9 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
})
const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
const config = yield* Config.Service
const prompt = yield* SessionPrompt.Service
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
yield* config.get()
const chat = yield* sessions.create(input ?? { title: "Pinned" })
return { prompt, run, sessions, chat }
})
@@ -1080,32 +1078,6 @@ unix("shell completes a fast command on the preferred shell", () =>
),
)
unix(
"shell uses configured shell over env shell",
() =>
withSh(() =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
if (!Bun.which("bash")) return
const { prompt, chat } = yield* boot()
const result = yield* prompt.shell({
sessionID: chat.id,
agent: "build",
command: "[[ 1 -eq 1 ]] && printf configured",
})
const tool = completedTool(result.parts)
if (!tool) return
expect(tool.state.output).toContain("configured")
}),
{ git: true, config: { ...cfg, shell: "bash" } },
),
),
30_000,
)
unix("shell commands can change directory after startup", () =>
provideTmpdirInstance(
(dir) =>
@@ -1291,45 +1263,6 @@ it.live(
3_000,
)
unix(
"command ! expansion uses configured shell over env shell",
() =>
withSh(() =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
if (!Bun.which("bash")) return
const { prompt, chat } = yield* boot()
yield* llm.text("done")
const result = yield* prompt.command({
sessionID: chat.id,
command: "probe",
arguments: "",
})
expect(result.info.role).toBe("assistant")
const inputs = yield* llm.inputs
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
}),
{
git: true,
config: (url) => ({
...providerCfg(url),
shell: "bash",
command: {
probe: {
template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
},
},
}),
},
),
),
30_000,
)
unix(
"cancel interrupts shell and resolves cleanly",
() =>
+1 -27
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "../../src/shell/shell"
import { Filesystem } from "../../src/util"
import { which } from "../../src/util/which"
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
const prev = process.env.SHELL
@@ -40,20 +39,6 @@ describe("shell", () => {
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
})
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = Shell.preferred()
const acceptable = Shell.acceptable()
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
})
})
test("falls back for terminal-only acceptable shells", () => {
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
})
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
@@ -77,19 +62,8 @@ describe("shell", () => {
})
})
test("resolves bare bash to Git Bash before PATH", async () => {
const bash = Shell.gitbash()
if (!bash) return
expect(Shell.acceptable("bash")).toBe(bash)
expect(Shell.preferred("bash")).toBe(bash)
await withShell("bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
})
})
test("resolves bare PowerShell shells", async () => {
const shell = which("pwsh") || which("powershell")
const shell = Bun.which("pwsh") || Bun.which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(Shell.preferred()).toBe(shell)
-29
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import os from "os"
import path from "path"
import { Config } from "../../src/config"
import { Shell } from "../../src/shell/shell"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
@@ -22,7 +21,6 @@ const runtime = ManagedRuntime.make(
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
Agent.defaultLayer,
),
)
@@ -155,33 +153,6 @@ describe("tool.bash", () => {
},
})
})
test("falls back from terminal-only configured shell", async () => {
await using tmp = await tmpdir({
config: { shell: "fish" },
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initBash()
const fallback = Shell.name(Shell.acceptable("fish"))
expect(fallback).not.toBe("fish")
expect(bash.description).toContain(fallback)
const result = await Effect.runPromise(
bash.execute(
{
command: "echo fallback",
description: "Echo fallback text",
},
ctx,
),
)
expect(result.metadata.exit).toBe(0)
expect(result.output).toContain("fallback")
},
})
})
})
describe("tool.bash permissions", () => {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"scripts": {
@@ -22,8 +22,8 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105"
"@opentui/core": ">=0.1.103",
"@opentui/solid": ">=0.1.103"
},
"peerDependenciesMeta": {
"@opentui/core": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"scripts": {
-31
View File
@@ -105,7 +105,6 @@ import type {
PtyListResponses,
PtyRemoveErrors,
PtyRemoveResponses,
PtyShellsResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
@@ -1081,36 +1080,6 @@ export class Project extends HeyApiClient {
}
export class Pty extends HeyApiClient {
/**
* List available shells
*
* Get a list of available shells on the system.
*/
public shells<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<PtyShellsResponses, unknown, ThrowOnError>({
url: "/pty/shells",
...options,
...params,
})
}
/**
* List PTY sessions
*
-27
View File
@@ -1470,10 +1470,6 @@ export type Config = {
* JSON schema reference for configuration validation
*/
$schema?: string
/**
* Default shell to use for terminal and bash tool
*/
shell?: string
logLevel?: LogLevel
server?: ServerConfig
/**
@@ -2698,29 +2694,6 @@ export type ProjectUpdateResponses = {
export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]
export type PtyShellsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/pty/shells"
}
export type PtyShellsResponses = {
/**
* List of shells
*/
200: Array<{
path: string
name: string
acceptable: boolean
}>
}
export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses]
export type PtyListData = {
body?: never
path?: never
-60
View File
@@ -1032,62 +1032,6 @@
]
}
},
"/pty/shells": {
"get": {
"operationId": "pty.shells",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "List available shells",
"description": "Get a list of available shells on the system.",
"responses": {
"200": {
"description": "List of shells",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"name": {
"type": "string"
},
"acceptable": {
"type": "boolean"
}
},
"required": ["path", "name", "acceptable"]
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})"
}
]
}
},
"/pty": {
"get": {
"operationId": "pty.list",
@@ -11510,10 +11454,6 @@
"description": "JSON schema reference for configuration validation",
"type": "string"
},
"shell": {
"description": "Default shell to use for terminal and bash tool",
"type": "string"
},
"logLevel": {
"$ref": "#/components/schemas/LogLevel"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.27",
"version": "1.14.25",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.27",
"version": "1.14.25",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
-10
View File
@@ -155,16 +155,6 @@ OpenCode Go حاليًا في المرحلة التجريبية.
---
### النماذج
يمكنك جلب القائمة الكاملة بالنماذج المتاحة وبياناتها الوصفية من:
```
https://opencode.ai/zen/go/v1/models
```
---
## الخصوصية
صُمّمت هذه الخطة أساسًا للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة لضمان وصول عالمي مستقر. ويتّبع مزودونا سياسة عدم الاحتفاظ بالبيانات، ولا يستخدمون بياناتك لتدريب النماذج.
-10
View File
@@ -169,16 +169,6 @@ koristi format `opencode-go/<model-id>`. Na primjer, za Kimi K2.6, koristili bis
---
### Modeli
Pun spisak dostupnih modela i njihovih metapodataka možete preuzeti na:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privatnost
Plan je prvenstveno namijenjen međunarodnim korisnicima, a modeli su smješteni u US, EU i Singaporeu radi stabilnog globalnog pristupa. Naši pružaoci usluga primjenjuju politiku nultog zadržavanja podataka i ne koriste vaše podatke za treniranje modela.
-15
View File
@@ -312,21 +312,6 @@ Available options:
---
### Shell
You can configure the shell used for the interactive terminal using the `shell` option. Compatible shells are also used for agent tool calls.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"shell": "pwsh"
}
```
If not specified, OpenCode will automatically discover and use a sensible default based on your operating system (e.g. `pwsh` or `cmd.exe` on Windows, `/bin/zsh` or `/bin/bash` on macOS/Linux). You can provide an absolute path or a short name.
---
### Tools
You can manage the tools an LLM can use through the `tools` option.
-10
View File
@@ -169,16 +169,6 @@ bruge `opencode-go/kimi-k2.6` i din config.
---
### Modeller
Du kan hente den fulde liste over tilgængelige modeller og deres metadata fra:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privatliv
Planen er primært designet til internationale brugere med modeller hostet i US, EU og Singapore for stabil global adgang. Vores udbydere følger en zero-retention-policy og bruger ikke dine data til modeltræning.
-10
View File
@@ -157,16 +157,6 @@ Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Fo
---
### Models
Du kannst die vollständige Liste der verfügbaren Modelle und ihrer Metadaten hier abrufen:
```
https://opencode.ai/zen/go/v1/models
```
---
## Datenschutz
Der Plan ist in erster Linie für internationale Nutzer konzipiert, mit in US, EU und Singapore gehosteten Modellen für einen stabilen weltweiten Zugriff. Unsere Anbieter befolgen eine Zero-Retention-Richtlinie und verwenden Ihre Daten nicht für das Modelltraining.
-10
View File
@@ -169,16 +169,6 @@ usa el formato `opencode-go/<model-id>`. Por ejemplo, para Kimi K2.6, usarías
---
### Modelos
Puedes obtener la lista completa de modelos disponibles y sus metadatos desde:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privacidad
El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en US, EU y Singapore para ofrecer un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.
-10
View File
@@ -155,16 +155,6 @@ L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilis
---
### Modèles
Vous pouvez récupérer la liste complète des modèles disponibles et leurs métadonnées à partir de :
```
https://opencode.ai/zen/go/v1/models
```
---
## Confidentialité
Cette offre est conçue avant tout pour les utilisateurs internationaux, avec des modèles hébergés aux US, dans lEU et à Singapore afin dassurer un accès mondial stable. Nos fournisseurs appliquent une politique de rétention zéro et nutilisent pas vos données pour lentraînement des modèles.
-10
View File
@@ -169,16 +169,6 @@ use `opencode-go/kimi-k2.6` in your config.
---
### Models
You can fetch the full list of available models and their metadata from:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privacy
The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.
-10
View File
@@ -167,16 +167,6 @@ utilizza il formato `opencode-go/<model-id>`. Ad esempio, per Kimi K2.6, userest
---
### Modelli
Puoi recuperare l'elenco completo dei modelli disponibili e i relativi metadati da:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privacy
Il piano è pensato principalmente per gli utenti internazionali, con modelli ospitati negli US, nellEU e a Singapore per un accesso globale stabile. I nostri provider seguono una politica di zero-retention e non utilizzano i tuoi dati per laddestramento dei modelli.
-10
View File
@@ -155,16 +155,6 @@ OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/<model-id>`
---
### モデル
利用可能なモデルとそのメタデータの完全な一覧は、次から取得できます。
```
https://opencode.ai/zen/go/v1/models
```
---
## プライバシー
このプランは主に海外ユーザー向けに設計されており、安定したグローバルアクセスのため、モデルは US、EU、Singapore でホストされています。各プロバイダーはデータを保持しないポリシーに従っており、お客様のデータをモデルのトレーニングに使用することはありません。
-10
View File
@@ -155,16 +155,6 @@ OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/<model-id>`
---
### 모델
사용 가능한 전체 모델 목록과 메타데이터는 다음에서 가져올 수 있습니다.
```
https://opencode.ai/zen/go/v1/models
```
---
## 개인정보 보호
이 플랜은 안정적인 전 세계 액세스를 위해 모델을 미국, EU, 싱가포르에 호스팅하며, 주로 해외 사용자를 위해 설계되었습니다. 저희 제공자는 zero-retention 정책을 따르며, 고객 데이터를 모델 학습에 사용하지 않습니다.
-10
View File
@@ -169,16 +169,6 @@ bruke `opencode-go/kimi-k2.6` i konfigurasjonen din.
---
### Modeller
Du kan hente hele listen over tilgjengelige modeller og metadataene deres fra:
```
https://opencode.ai/zen/go/v1/models
```
---
## Personvern
Planen er primært utformet for internasjonale brukere, med modeller hostet i US, EU og Singapore for stabil global tilgang. Våre leverandører følger en zero-retention-policy og bruker ikke dataene dine til modelltrening.
-10
View File
@@ -161,16 +161,6 @@ używa formatu `opencode-go/<model-id>`. Na przykład dla Kimi K2.6 należy uży
---
### Modele
Pełną listę dostępnych modeli i ich metadane możesz pobrać z:
```
https://opencode.ai/zen/go/v1/models
```
---
## Prywatność
Plan został zaprojektowany przede wszystkim z myślą o użytkownikach międzynarodowych, a modele są hostowane w US, EU i Singapore, aby zapewnić stabilny dostęp na całym świecie. Nasi dostawcy stosują politykę zerowej retencji i nie wykorzystują Twoich danych do trenowania modeli.
@@ -169,16 +169,6 @@ usa o formato `opencode-go/<model-id>`. Por exemplo, para o Kimi K2.6, você usa
---
### Modelos
Você pode buscar a lista completa de modelos disponíveis e seus metadados em:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privacidade
O plano foi projetado principalmente para usuários internacionais, com modelos hospedados em US, EU e Singapore para garantir acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.
-10
View File
@@ -169,16 +169,6 @@ OpenCode Go включает следующие лимиты:
---
### Модели
Вы можете получить полный список доступных моделей и их метаданных по адресу:
```
https://opencode.ai/zen/go/v1/models
```
---
## Конфиденциальность
Этот план разработан в первую очередь для международных пользователей: модели размещены в US, EU и Singapore, чтобы обеспечить стабильный доступ по всему миру. Наши провайдеры придерживаются политики zero-retention и не используют ваши данные для обучения моделей.
-10
View File
@@ -155,16 +155,6 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้:
---
### Models
คุณสามารถดึงรายการโมเดลทั้งหมดที่พร้อมใช้งานและ metadata ของมันได้จาก:
```
https://opencode.ai/zen/go/v1/models
```
---
## Privacy
แพลนนี้ออกแบบมาสำหรับผู้ใช้ทั่วโลกเป็นหลัก โดยโฮสต์โมเดลไว้ใน US, EU และ Singapore เพื่อให้เข้าถึงได้อย่างเสถียรจากทั่วโลก ผู้ให้บริการของเราปฏิบัติตามนโยบาย zero-retention และไม่นำข้อมูลของคุณไปใช้ในการฝึกโมเดล
-10
View File
@@ -155,16 +155,6 @@ OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `openc
---
### Modeller
Mevcut modellerin tam listesini ve metadata'larını şuradan alabilirsiniz:
```
https://opencode.ai/zen/go/v1/models
```
---
## Gizlilik
Plan, öncelikle uluslararası kullanıcılar için tasarlanmıştır; dünya genelinde istikrarlı erişim sağlamak için modeller US, EU ve Singapore'da barındırılır. Sağlayıcılarımız sıfır veri saklama politikası uygular ve verilerinizi model eğitimi için kullanmaz.
@@ -155,16 +155,6 @@ OpenCode Go 包含以下限制:
---
### 模型
你可以从以下地址获取可用模型及其元数据的完整列表:
```
https://opencode.ai/zen/go/v1/models
```
---
## 隐私保护
该方案主要面向国际用户,模型托管在 US、EU 和 Singapore,以提供稳定的全球访问。我们的提供商遵循零保留政策,不会将您的数据用于模型训练。
@@ -155,16 +155,6 @@ OpenCode Go 包含以下限制:
---
### 模型
你可以從以下位置取得所有可用模型及其中繼資料的完整清單:
```
https://opencode.ai/zen/go/v1/models
```
---
## 隱私權
此方案主要為國際使用者設計,模型部署於 US、EU 與 Singapore,以提供穩定的全球存取體驗。我們的供應商遵循零保留政策,不會將你的資料用於模型訓練。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.27",
"version": "1.14.25",
"publisher": "sst-dev",
"repository": {
"type": "git",