Merge remote-tracking branch 'upstream/dev' into refactor-shells

This commit is contained in:
LukeParkerDev 2026-04-27 14:15:07 +10:00
commit b1d9c57655
74 changed files with 2002 additions and 813 deletions

3
.github/VOUCHED.td vendored
View file

@ -12,8 +12,10 @@ adamdotdevin
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-carycooper777
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
edemaine
-florianleibert
fwang
@ -33,4 +35,3 @@ simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person

View file

@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.26",
"version": "1.14.27",
"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.26",
"version": "1.14.27",
"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.26",
"version": "1.14.27",
"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.26",
"version": "1.14.27",
"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.26",
"version": "1.14.27",
"bin": {
"opencode": "./bin/opencode",
},
@ -226,7 +226,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@ -259,7 +259,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@ -303,7 +303,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@ -332,7 +332,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@ -348,7 +348,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.26",
"version": "1.14.27",
"bin": {
"opencode": "./bin/opencode",
},
@ -491,7 +491,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@ -506,8 +506,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.104",
"@opentui/solid": ">=0.1.104",
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
},
"optionalPeers": [
"@opentui/core",
@ -526,7 +526,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"cross-spawn": "catalog:",
},
@ -541,7 +541,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -576,7 +576,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.26",
"version": "1.14.27",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -625,7 +625,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.26",
"version": "1.14.27",
"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.104",
"@opentui/solid": "0.1.104",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@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.104", "", { "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.104", "@opentui/core-darwin-x64": "0.1.104", "@opentui/core-linux-arm64": "0.1.104", "@opentui/core-linux-x64": "0.1.104", "@opentui/core-win32-arm64": "0.1.104", "@opentui/core-win32-x64": "0.1.104", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-29RZ+f7sw5B6ebDFRuhrDs2UOV7vR76npr0vVRAJAmIMtdLb4Dse35Y0Hk6WerFKNuX4ajlQK4eO3oAY/29KLQ=="],
"@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.104", "", { "os": "darwin", "cpu": "arm64" }, "sha512-83Bf+LOLYCgWccAuPzftchs8LIoTzYU8f8CM7GeUFqH0VLKs2Ey+TtCTsBWpeDklOal0XFQr5begotqc/ldPVg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.104", "", { "os": "darwin", "cpu": "x64" }, "sha512-TAHRtEi7Gz2O3TXolPCiWpXsoapKllGCl74bOuJsyipMwfk3wuUu6SeqBPC/cmVJlp143dC8kcGLwLsd3dxsgA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.104", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJ8ssxcRpoHWgGLDYlVjlYZGkmA2G9melgXTrzk6CbwhW5tVAE6AmW5Kq9DUHclYssCpNWTvZFMDsVsf5kn2Xw=="],
"@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-x64": ["@opentui/core-linux-x64@0.1.104", "", { "os": "linux", "cpu": "x64" }, "sha512-I4DurCkJXJxxGwAq1wGZ1vzXh57GxdzljWeCJ5sIPy7coICn+/cWRw9gE9VxiWTa3T85l4YN0UFSQx7IFCEbvg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.104", "", { "os": "win32", "cpu": "arm64" }, "sha512-PDwqGHlVUiAiZa8GXiPPJVWIxucQ5+2FLcK4C76VT8HJzUXiUEGJFtuvi9oY7k29p8olcFHW75Gj1WMUrw+JKw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.104", "", { "os": "win32", "cpu": "x64" }, "sha512-fQGMxFshk/i7KskqaK5lB8u2K8Lx/LO9+PdfKu/+GBIMKmElpJXQ8Bq2sMf+7COcoXcjAjUJn8cpTyIqbFxAyQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/solid": ["@opentui/solid@0.1.104", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.104", "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-Mma1OR3s0QZayRGdbdNv1m3H6IExRrF9QRUNWEZzDOqo3oNxTxaY9K7Qk6eGrk0unMjwwE113TSOdnG80RwYnQ=="],
"@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=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

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-U/LZx/D+5JTT1LHSyZkEuqXP/ky7LkHrEYBW5pcVArk=",
"aarch64-linux": "sha256-nGZa04h4y3jbdmf87IRrlQm/E5qYR8lj5OxKgQSR2XU=",
"aarch64-darwin": "sha256-GD8pCHWMBppDaIfRKxhY2m4xWo1OrY3wOmGw+EC71mw=",
"x86_64-darwin": "sha256-KOH1ZB8pdpF7Xer6QIH7rrr9fwF/BZkCTJndPe0wypg="
}
}

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.104",
"@opentui/solid": "0.1.104",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",

View file

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

View file

@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import {
monoDefault,
monoFontFamily,
@ -40,6 +42,18 @@ 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 = () => {
@ -75,10 +89,6 @@ export const SettingsGeneral: Component = () => {
const params = useParams()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
@ -165,6 +175,70 @@ 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") },
@ -243,6 +317,27 @@ 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")}
@ -651,70 +746,32 @@ 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()}>
{(_) => {
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
<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 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>
)
}}
<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>
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>

View file

@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
}),
),
]
@ -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", input.global.config)
input.setStore("config", reconcile(input.global.config, { merge: false }))
}
if (loading || input.store.provider.all.length === 0) {
input.setStore("provider_ready", false)
@ -265,7 +265,8 @@ 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", x.data!))),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => 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))),

View file

@ -728,6 +728,11 @@ 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",

View file

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

View file

@ -204,6 +204,14 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconDeepSeek(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.249-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 0 1-.465.137 9.597 9.597 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 0 1 .415-.287.302.302 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 0 1-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" />
</svg>
)
}
export function IconMiMo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">

View file

@ -12,7 +12,7 @@ import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { config } from "~/config"
import { getLastSeenWorkspaceID } from "../workspace/common"
import { IconMiniMax, IconMiMo, IconZai, IconAlibaba } from "~/component/icon"
import { IconMiniMax, IconMiMo, IconZai, IconAlibaba, IconDeepSeek } from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LocaleLinks } from "~/component/locale-links"
@ -340,6 +340,9 @@ export default function Home() {
<div>
<IconAlibaba width="24" height="24" />
</div>
<div>
<IconDeepSeek width="24" height="24" />
</div>
<div>
<IconMiMo width="24" height="24" />
</div>

View file

@ -1,10 +1,12 @@
import type { APIEvent } from "@solidjs/start/server"
import { getHandler, optionsHandler } from "../../util/modelsHandler"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { buildModelsResponse, buildOptionsResponse } from "../../util/modelsHandler"
export async function OPTIONS(_input: APIEvent) {
return optionsHandler()
return buildOptionsResponse()
}
export async function GET(input: APIEvent) {
return getHandler({ modelList: "lite" })
export async function GET(_input: APIEvent) {
const models = Object.keys(ZenData.list("lite").models)
return buildModelsResponse(models)
}

View file

@ -1,6 +1,4 @@
import { ZenData } from "@opencode-ai/console-core/model.js"
export async function optionsHandler() {
export async function buildOptionsResponse() {
return new Response(null, {
status: 200,
headers: {
@ -11,16 +9,13 @@ export async function optionsHandler() {
})
}
export async function getHandler(opts: { modelList: "lite" | "full"; disabledModels?: string[] }) {
const zenData = ZenData.list(opts.modelList)
export async function buildModelsResponse(models: string[]) {
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]) => ({
data: models
.filter((id) => !id.startsWith("alpha-"))
.map((id) => ({
id,
object: "model",
created: Math.floor(Date.now() / 1000),

View file

@ -1,18 +1,19 @@
import type { APIEvent } from "@solidjs/start/server"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
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 { buildOptionsResponse, buildModelsResponse } from "~/routes/zen/util/modelsHandler"
export async function OPTIONS(_input: APIEvent) {
return optionsHandler()
return buildOptionsResponse()
}
export async function GET(input: APIEvent) {
const disabledModels = await (() => {
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
if (!apiKey) return []
if (!apiKey) return [] as string[]
return Database.use((tx) =>
tx
@ -27,5 +28,7 @@ export async function GET(input: APIEvent) {
)
})()
return getHandler({ modelList: "full", disabledModels })
const models = Object.keys(ZenData.list("full").models).filter((id) => !disabledModels.includes(id))
return buildModelsResponse(models)
}

View file

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

View file

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

View file

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

View file

@ -1,12 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.26",
"version": "1.14.27",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"bin": {

View file

@ -0,0 +1,40 @@
export * as NpmConfig from "./npm-config"
import { fileURLToPath } from "url"
// @ts-expect-error npm does not publish types for this internal config API.
import Config from "@npmcli/config"
// @ts-expect-error npm does not publish types for this internal config API.
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect } from "effect"
const npmPath = fileURLToPath(new URL("..", import.meta.url))
export const load = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
npmPath,
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath],
execPath: process.execPath,
platform: process.platform,
definitions,
flatten,
nerfDarts,
shorthands,
warn: false,
})
await config.load()
return config.flat as Record<string, unknown>
},
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => ({}) as Record<string, unknown>))
export const registry = (dir: string) =>
load(dir).pipe(
Effect.map((config) => {
const registry = typeof config.registry === "string" ? config.registry : "https://registry.npmjs.org"
return registry.endsWith("/") ? registry.slice(0, -1) : registry
}),
)

View file

@ -1,22 +1,14 @@
export * as Npm from "./npm"
import path from "path"
import { fileURLToPath } from "url"
import npa from "npm-package-arg"
import semver from "semver"
// @ts-expect-error npm does not publish types for this internal config API.
import Config from "@npmcli/config"
// @ts-expect-error npm does not publish types for this internal config API.
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock"
import { makeRuntime } from "./effect/runtime"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
import { NpmConfig } from "./npm-config"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
@ -40,46 +32,18 @@ export interface Interface {
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
const npmPath = fileURLToPath(new URL("..", import.meta.url))
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
const loadOptions = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
npmPath,
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath],
execPath: process.execPath,
platform: process.platform,
definitions,
flatten,
nerfDarts,
shorthands,
warn: false,
})
await config.load()
return config.flat
},
catch: (cause) =>
new InstallFailedError({
cause,
dir,
}),
})
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
@ -110,39 +74,13 @@ export const layer = Layer.effect(
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const runView = Effect.fnUntraced(function* (cmd: string[]) {
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
extendEnv: true,
}),
)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
if (code !== 0 || !stdout.trim()) {
return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`)
}
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout)
}, Effect.scoped)
const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) {
return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
Effect.catch(() =>
runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])),
),
),
)
})
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* loadOptions(input.dir)
const npmOptions = yield* NpmConfig.load(input.dir)
const arborist = new Arborist({
...npmOptions,
path: input.dir,
@ -172,18 +110,6 @@ export const layer = Layer.effect(
}),
)
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
if (Option.isNone(latestVersion)) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion.value, cachedVersion)
return semver.lt(cachedVersion, latestVersion.value)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const name = (() => {
@ -309,7 +235,6 @@ export const layer = Layer.effect(
return Service.of({
add,
install,
outdated,
which,
})
}),
@ -320,7 +245,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
@ -337,10 +261,6 @@ export async function add(...args: Parameters<Interface["add"]>) {
}
}
export async function outdated(...args: Parameters<Interface["outdated"]>) {
return runPromise((svc) => svc.outdated(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)

View file

@ -0,0 +1,13 @@
import fs from "fs/promises"
import { tmpdir as osTmpdir } from "os"
import path from "path"
export const tmpdir = async () => {
const dir = await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-"))
return {
path: dir,
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
}

View file

@ -0,0 +1,51 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { NpmConfig } from "@opencode-ai/core/npm-config"
import { tmpdir } from "./fixture/tmpdir"
describe("NpmConfig.load", () => {
test("reads registry from project .npmrc", async () => {
await using tmp = await tmpdir()
await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
const config = await Effect.runPromise(NpmConfig.load(tmp.path))
expect(config.registry).toBe("https://registry.example.test/")
})
test("reads scoped registries from project .npmrc", async () => {
await using tmp = await tmpdir()
await Bun.write(path.join(tmp.path, ".npmrc"), "@acme:registry=https://npm.acme.test/\n")
const config = await Effect.runPromise(NpmConfig.load(tmp.path))
expect(config["@acme:registry"]).toBe("https://npm.acme.test/")
})
test("flattens boolean and list options", async () => {
await using tmp = await tmpdir()
await Bun.write(path.join(tmp.path, ".npmrc"), "ignore-scripts=true\nomit[]=dev\nomit[]=optional\n")
const config = await Effect.runPromise(NpmConfig.load(tmp.path))
expect(config.ignoreScripts).toBe(true)
expect(config.omit).toEqual(["dev", "optional"])
})
})
describe("NpmConfig.registry", () => {
test("normalizes configured registry without trailing slash", async () => {
await using tmp = await tmpdir()
await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test")
})
test("leaves configured registry without trailing slash unchanged", async () => {
await using tmp = await tmpdir()
await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test\n")
await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test")
})
})

View file

@ -0,0 +1,56 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Npm } from "@opencode-ai/core/npm"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
Bun.write(
path.join(dir, "package.json"),
JSON.stringify({
version: "1.0.0",
...pkg,
}),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
expect(Npm.sanitize("prettier")).toBe("prettier")
})
test("handles git https specs", () => {
const spec = "acme@git+https://github.com/opencode/acme.git"
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
expect(Npm.sanitize(spec)).toBe(expected)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
})
})

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.26",
"version": "1.14.27",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.26",
"version": "1.14.27",
"name": "opencode",
"type": "module",
"license": "MIT",

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` | `special` | SSE |
| `event` | `bridged` | SSE via raw Effect HTTP |
| `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
- [ ] `GET /event` - SSE event stream; replace with raw Effect HTTP, not `HttpApi`.
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
### PTY Routes
- [ ] `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.
- [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.
### TUI Routes
- [ ] `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.
- [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.
## 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. [ ] 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.
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.
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.

View file

@ -29,7 +29,8 @@ 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, useConnected } from "@tui/component/dialog-model"
import { DialogModel } from "@tui/component/dialog-model"
import { useConnected } from "@tui/component/use-connected"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"

View file

@ -8,13 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
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)),
)
}
import { useConnected } from "./use-connected"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()

View file

@ -14,6 +14,7 @@ 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,
@ -30,6 +31,7 @@ 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,
@ -49,7 +51,7 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected ? <text fg={theme.success}></text> : undefined,
gutter: connected && onboarded() ? <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return

View file

@ -6,8 +6,7 @@ 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 { errorData, errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@ -17,8 +16,6 @@ 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,
@ -37,18 +34,9 @@ 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((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
@ -56,24 +44,11 @@ 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",
@ -85,10 +60,6 @@ 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
}
@ -104,27 +75,10 @@ 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((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
.catch(() => 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",
@ -132,33 +86,11 @@ 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)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
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,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
input.toast.show({
message: "Session restored into the new workspace",
@ -230,47 +162,26 @@ 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((err) => {
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
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)
}

View file

@ -0,0 +1,9 @@
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)),
)
}

View file

@ -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/dialog-model"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"

View file

@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
import { Global } from "@opencode-ai/core/global"
import fs from "fs/promises"
@ -58,7 +57,7 @@ export const UninstallCommand = {
UI.empty()
prompts.intro("Uninstall OpenCode")
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const method = await Installation.method()
prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method)

View file

@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@ -26,7 +25,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@ -44,9 +43,7 @@ export const UpgradeCommand = {
}
}
prompts.log.info("Using method: " + method)
const target = args.target
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
if (InstallationVersion === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@ -57,9 +54,7 @@ export const UpgradeCommand = {
prompts.log.info(`From ${InstallationVersion}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
(err) => err,
)
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) {

View file

@ -8,8 +8,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@ -27,7 +27,7 @@ export async function upgrade() {
}
if (method === "unknown") return
await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
}

View file

@ -99,6 +99,9 @@ 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",
@ -311,10 +314,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
return applyEdits(input, edits)
}
return Object.entries(patch).reduce((result, [key, value]) => {
if (value === undefined) return result
return patchJsonc(result, value, [...path, key])
}, input)
return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
}
function writable(info: Info) {
@ -322,6 +322,13 @@ 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({
@ -754,15 +761,16 @@ 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), writable(config))
const merged = mergeDeep(writable(existing), patch)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, writable(config))
const updated = patchJsonc(before, patch)
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}

View file

@ -8,9 +8,10 @@ import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Log } from "../util"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config"
const log = Log.create({ service: "installation" })
@ -132,17 +133,6 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
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])
if (result.code !== 0 || !result.stdout.trim()) {
return yield* new UpgradeFailedError({
stderr: result.stderr || result.stdout || `Failed to resolve ${spec}`,
})
}
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout)
})
const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
@ -173,163 +163,165 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.orDie,
)
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
}
}
return "unknown" as Method
})
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* methodImpl())
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.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
}
}
}
result = yield* run(["brew", "upgrade", formula], { env })
break
}
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({
const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
return {
version: InstallationVersion,
latest: yield* latestImpl(),
latest: yield* result.latest(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
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"]) },
]
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
}
}
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") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
`${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
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),
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
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}` })
}
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"])
}),
}
return Service.of(result)
}),
)
@ -338,4 +330,10 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((s) => s.latest(...args))
export const method = () => runPromise((s) => s.method())
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
export * as Installation from "."

View file

@ -1,17 +1,17 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Config } from "@/config"
import { InstanceState, EffectBridge } from "@/effect"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Plugin } from "@/plugin"
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,8 +117,10 @@ 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()
@ -174,8 +176,9 @@ 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()
const command = input.command || Shell.preferred(cfg.shell)
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
@ -360,6 +363,10 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Config.defaultLayer),
)
export * as Pty from "."

View file

@ -101,7 +101,7 @@ const app = (upgrade: UpgradeWebSocket) =>
}),
)
const log = Log.Default.clone().tag("service", "server-proxy")
const log = Log.create({ service: "server-proxy" })
export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
if (!Workspace.isSyncing(workspaceID)) {

View file

@ -0,0 +1,46 @@
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)),
)

View file

@ -0,0 +1,205 @@
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)),
)

View file

@ -1,25 +1,31 @@
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"
@ -66,17 +72,25 @@ 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),

View file

@ -0,0 +1,286 @@
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),
)
}),
)

View file

@ -16,12 +16,15 @@ 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"
@ -41,6 +44,7 @@ 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))
@ -94,6 +98,12 @@ 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))
@ -121,6 +131,19 @@ 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

View file

@ -6,14 +6,41 @@ 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({

View file

@ -12,15 +12,23 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
const TuiRequest = z.object({
export const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
type TuiRequest = z.infer<typeof TuiRequest>
export type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<any>()
const response = new AsyncQueue<unknown>()
export function nextTuiRequest() {
return request.next()
}
export function submitTuiResponse(body: unknown) {
response.push(body)
}
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
@ -50,7 +58,7 @@ const TuiControlRoutes = new Hono()
},
}),
async (c) => {
const req = await request.next()
const req = await nextTuiRequest()
return c.json(req)
},
)
@ -74,7 +82,7 @@ const TuiControlRoutes = new Hono()
validator("json", z.any()),
async (c) => {
const body = c.req.valid("json")
response.push(body)
submitTuiResponse(body)
return c.json(true)
},
)

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 { ConfigMarkdown } from "../config"
import { Config, ConfigMarkdown } from "../config"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/core/util/error"
import { SessionProcessor } from "./processor"
@ -93,6 +93,7 @@ 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,49 +785,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
yield* sessions.updatePart(part)
const sh = Shell.preferred()
const shellName = (
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
).toLowerCase()
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const cwd = ctx.directory
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 args = Shell.args(sh, input.command, cwd)
const shellEnv = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
@ -843,7 +805,6 @@ 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) {
@ -1589,7 +1550,8 @@ 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 sh = Shell.preferred()
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
@ -1690,6 +1652,7 @@ 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),

View file

@ -7,10 +7,23 @@ 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 },
}
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 type Item = {
path: string
name: string
acceptable: boolean
}
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
@ -50,22 +63,53 @@ 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 pick() {
const pwsh = which("pwsh.exe")
if (pwsh) return pwsh
const powershell = which("powershell.exe")
if (powershell) return powershell
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 select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
if (process.platform === "win32") {
const shell = pick()
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]!
return fallback()
}
@ -79,11 +123,6 @@ 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
@ -96,15 +135,81 @@ export function name(file: string) {
}
export function login(file: string) {
return LOGIN.has(name(file))
return meta(file)?.login === true
}
export function posix(file: string) {
return POSIX.has(name(file))
return meta(file)?.posix === true
}
export const preferred = lazy(() => select(process.env.SHELL))
export function ps(file: string) {
return meta(file)?.ps === true
}
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
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 * as Shell from "./shell"

View file

@ -10,6 +10,7 @@ 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"
import { ShellKind, ShellToolID } from "./shell/id"
@ -25,7 +26,6 @@ export { Parameters } from "./shell/prompt"
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,
@ -267,8 +267,8 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan)
})
})
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) {
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
@ -285,7 +285,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
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, {
@ -316,6 +315,7 @@ const parser = lazy(async () => {
export const ShellTool = Tool.define(
ShellToolID.id,
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const trunc = yield* Truncate.Service
@ -397,7 +397,6 @@ export const ShellTool = Tool.define(
const run = Effect.fn("ShellTool.run")(function* (
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
@ -427,7 +426,7 @@ export const ShellTool = Tool.define(
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
@ -556,7 +555,8 @@ export const ShellTool = Tool.define(
return () =>
Effect.gen(function* () {
const shell = Shell.acceptable()
const cfg = yield* config.get()
const shell = Shell.acceptable(cfg.shell)
const name = Shell.name(shell)
const limits = yield* trunc.limits()
const prompt = ShellPrompt.render(name, process.platform, limits)
@ -574,7 +574,7 @@ export const ShellTool = Tool.define(
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const ps = Shell.ps(shell)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
@ -583,7 +583,6 @@ export const ShellTool = Tool.define(
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),

View file

@ -55,6 +55,8 @@ 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 = () =>
@ -142,6 +144,106 @@ 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) => {
@ -886,7 +988,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
outdated: () => Effect.succeed(false),
which: () => Effect.succeed(Option.none()),
})
const testLayer = Config.layer.pipe(

View file

@ -69,64 +69,46 @@ describe("installation", () => {
expect(result).toBe("4.0.0-beta.1")
})
test("reads npm versions via npm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
return ""
},
)
test("reads npm versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
return jsonResponse({ version: "1.5.0" })
})
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.5.0")
expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads npm versions via bun pm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
return ""
},
)
test("reads bun versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
return jsonResponse({ version: "1.6.0" })
})
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.6.0")
expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads npm versions via pnpm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n'
return ""
},
)
test("reads pnpm versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
return jsonResponse({ version: "1.7.0" })
})
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.7.0")
expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads scoop manifest versions", async () => {

View file

@ -1,143 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Npm } from "@opencode-ai/core/npm"
import { tmpdir } from "./fixture/fixture"
const win = process.platform === "win32"
const encoder = new TextEncoder()
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const output = handler(std?.command ?? "", std?.args ?? [])
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
}),
)
})
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
}
function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) {
return Npm.layer.pipe(
Layer.provide(mockSpawner(spawnHandler)),
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
}
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
Bun.write(
path.join(dir, "package.json"),
JSON.stringify({
version: "1.0.0",
...pkg,
}),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
expect(Npm.sanitize("prettier")).toBe("prettier")
})
test("handles git https specs", () => {
const spec = "acme@git+https://github.com/opencode/acme.git"
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
expect(Npm.sanitize(spec)).toBe(expected)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
})
})
describe("Npm.outdated", () => {
test("checks latest via npm view", async () => {
const calls: string[][] = []
const layer = testLayer((cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n'
return ""
})
const result = await Effect.runPromise(
Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
)
expect(result).toBe(true)
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
})
test("keeps range comparison behavior", async () => {
const layer = testLayer((cmd, args) => {
if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n'
return ""
})
const result = await Effect.runPromise(
Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)),
)
expect(result).toBe(false)
})
test("falls back when npm view is unavailable", async () => {
const calls: string[][] = []
const layer = testLayer((cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n'
return ""
})
const result = await Effect.runPromise(
Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
)
expect(result).toBe(true)
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"])
})
})

View file

@ -67,3 +67,38 @@ 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 },
)
})

View file

@ -0,0 +1,50 @@
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')
})
})

View file

@ -0,0 +1,74 @@
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)
})
})

View file

@ -0,0 +1,79 @@
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 })
})
})

View file

@ -316,9 +316,11 @@ 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 }
})
@ -1078,6 +1080,32 @@ 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) =>
@ -1263,6 +1291,45 @@ 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",
() =>

View file

@ -2,6 +2,7 @@ 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
@ -39,6 +40,20 @@ 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 () => {
@ -62,8 +77,19 @@ 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 = Bun.which("pwsh") || Bun.which("powershell")
const shell = which("pwsh") || which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(Shell.preferred()).toBe(shell)

View file

@ -2,6 +2,7 @@ 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 { ShellToolID } from "../../src/tool/shell/id"
import { ShellTool } from "../../src/tool/shell"
@ -22,6 +23,7 @@ const runtime = ManagedRuntime.make(
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
Agent.defaultLayer,
),
)
@ -158,6 +160,33 @@ describe("tool.shell", () => {
},
})
})
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.shell permissions", () => {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.26",
"version": "1.14.27",
"type": "module",
"license": "MIT",
"scripts": {
@ -22,8 +22,8 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.1.104",
"@opentui/solid": ">=0.1.104"
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105"
},
"peerDependenciesMeta": {
"@opentui/core": {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.26",
"version": "1.14.27",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -105,6 +105,7 @@ import type {
PtyListResponses,
PtyRemoveErrors,
PtyRemoveResponses,
PtyShellsResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
@ -1080,6 +1081,36 @@ 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
*

View file

@ -1470,6 +1470,10 @@ 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
/**
@ -2694,6 +2698,29 @@ 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

View file

@ -1032,6 +1032,62 @@
]
}
},
"/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",
@ -11454,6 +11510,10 @@
"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"
},

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.26",
"version": "1.14.27",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.26",
"version": "1.14.27",
"type": "module",
"license": "MIT",
"exports": {

View file

@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.26",
"version": "1.14.27",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View file

@ -312,6 +312,21 @@ 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.

View file

@ -23,7 +23,7 @@ type Diff = {
}
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
const bot = ["actions-user", "github-actions[bot]", "opencode", "opencode-agent[bot]"]
const team = [
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
.text()

View file

@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.26",
"version": "1.14.27",
"publisher": "sst-dev",
"repository": {
"type": "git",