Compare commits

..
1 Commits
Author SHA1 Message Date
LukeParkerDev 9abcab6643 perf(app): mount the shell before the servers are known
The desktop gated the whole interface on the local service, WSL and SSH
discovery. The shell (titlebar, tabs, layout) now mounts from local
state as soon as the quick lookups resolve, and the server list is
marked pending while connections are still being discovered: the tabs
store does not prune tabs of servers that have not appeared yet, the
layout does not fall back to the connect screen, and draft and session
routes hold their panel's place until their connection exists.
2026-09-19 13:58:11 +10:00
92 changed files with 453 additions and 772 deletions
+36 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.8",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,10 +201,11 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -214,7 +215,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +251,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +278,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +295,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +319,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +339,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,7 +407,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -455,7 +456,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +493,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +528,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +541,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +555,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +570,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +609,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +639,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +654,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +678,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +699,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +721,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +756,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +776,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +810,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +829,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +875,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +889,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +924,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +959,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +992,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1033,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.8",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.8",
"description": "",
"type": "module",
"exports": {
+2
View File
@@ -102,6 +102,7 @@ export function AppInterface(props: {
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
serversPending?: boolean
router?: Component<BaseRouterProps>
}) {
// The visual layout lives in the router root so it remains mounted across
@@ -128,6 +129,7 @@ export function AppInterface(props: {
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
pending={props.serversPending}
>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
+14 -2
View File
@@ -7,7 +7,7 @@ import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider, useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
@@ -30,11 +30,23 @@ export function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const servers = useServers()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
<Show
when={conn()}
keyed
fallback={
// The shell is up before the local service has connected; hold the panel's place.
<Show when={servers.pending}>
<SessionRouteFrame padded>
<SessionPanelFrame raised />
</SessionRouteFrame>
</Show>
}
>
{(conn) => (
<ServerProvider conn={conn}>
<ResolvedDraftContent draft={props.draft} />
@@ -199,6 +199,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
// The host is still discovering connections (desktop: the local service, WSL, SSH). The shell
// renders meanwhile; nothing that depends on the list being complete may act on it yet.
pending?: boolean
}) => {
const [store, setStore, _] = persisted(
{
@@ -258,6 +261,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get visible() {
return visibleServers()
},
get pending() {
return props.pending ?? false
},
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
+13 -2
View File
@@ -62,12 +62,23 @@ export function AppRoutes() {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const servers = useServers()
const connection = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
<Show when={connection()} keyed>
<Show
when={connection()}
keyed
fallback={
<Show when={servers.pending}>
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
</Show>
}
>
{(connection) => <ServerProvider conn={connection}>{props.children}</ServerProvider>}
</Show>
)
@@ -76,7 +87,7 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<Show when={servers.list.length > 0 || servers.pending} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
+4 -2
View File
@@ -138,8 +138,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
onCleanup(memory.dispose)
// Tabs of a server that is gone are dropped, but not while the host is still discovering its
// servers: the shell mounts before the local service has connected.
createEffect(() => {
if (!ready() || !recentReady()) return
if (!ready() || !recentReady() || servers.pending) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
@@ -165,7 +167,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
createEffect(() => {
if (!closedReady()) return
if (!closedReady() || servers.pending) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"bin": {
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode/script"
import { $ } from "bun"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { UpdateArtifact } from "../../../script/update-artifact"
if (Script.channel !== "beta" && Script.channel !== "latest") {
throw new Error("Homebrew publishing requires the beta or latest channel")
}
const valid =
Script.channel === "beta"
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
: /^\d+\.\d+\.\d+$/.test(Script.version)
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
const dir = fileURLToPath(new URL("..", import.meta.url))
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
const outdir = path.join(root, "homebrew-tap")
const dryRun = process.argv.includes("--dry-run")
const name = Script.channel === "beta" ? "opencode-beta" : "opencode-v2"
const formulaClass = Script.channel === "beta" ? "OpencodeBeta" : "OpencodeV2"
const targets = await Promise.all(
[
{ name: "darwin-arm64", archive: "zip" },
{ name: "darwin-x64-baseline", archive: "zip" },
{ name: "linux-arm64", archive: "tar.gz" },
{ name: "linux-x64-baseline", archive: "tar.gz" },
].map(async (target) => {
const filename = `opencode-${target.name}.${target.archive}`
const file = Bun.file(path.join(root, filename))
if (!(await file.exists()) || !file.size) throw new Error(`Missing Homebrew archive: ${filename}`)
const sha256 = new Bun.CryptoHasher("sha256")
for await (const chunk of file.stream()) sha256.update(chunk)
return {
...target,
url: `https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${filename}`,
sha256: sha256.digest("hex"),
}
}),
)
await rm(outdir, { recursive: true, force: true })
if (dryRun) await mkdir(outdir, { recursive: true })
if (!dryRun) {
const token = process.env.GITHUB_TOKEN
if (!token) throw new Error("GITHUB_TOKEN is required to update the Homebrew tap")
await $`git clone ${`https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`} ${outdir}`
await $`git checkout -B master`.cwd(outdir)
}
const target = (name: string) => {
const result = targets.find((item) => item.name === name)
if (!result) throw new Error(`Missing Homebrew target: ${name}`)
return result
}
const macArm = target("darwin-arm64")
const macIntel = target("darwin-x64-baseline")
const linuxArm = target("linux-arm64")
const linuxIntel = target("linux-x64-baseline")
await Bun.write(
path.join(outdir, `${name}.rb`),
[
"# typed: false",
"# frozen_string_literal: true",
"",
`class ${formulaClass} < Formula`,
` desc "OpenCode V2${Script.channel === "beta" ? " beta" : ""} - the AI coding agent for the terminal"`,
' homepage "https://github.com/anomalyco/opencode"',
` version "${Script.version}"`,
' license "MIT"',
"",
' depends_on "ripgrep"',
' conflicts_with "opencode", because: "both install an opencode binary"',
"",
" on_macos do",
" if Hardware::CPU.arm?",
` url "${macArm.url}"`,
` sha256 "${macArm.sha256}"`,
" else",
` url "${macIntel.url}"`,
` sha256 "${macIntel.sha256}"`,
" end",
" end",
"",
" on_linux do",
" if Hardware::CPU.arm?",
` url "${linuxArm.url}"`,
` sha256 "${linuxArm.sha256}"`,
" else",
` url "${linuxIntel.url}"`,
` sha256 "${linuxIntel.sha256}"`,
" end",
" end",
"",
" def install",
' bin.install "opencode"',
" end",
"end",
"",
].join("\n"),
)
console.log(`Prepared ${name} ${Script.version} in ${outdir}`)
if (dryRun) process.exit(0)
await $`git add ${name + ".rb"}`.cwd(outdir)
if ((await $`git diff --cached --quiet`.cwd(outdir).nothrow()).exitCode !== 0) {
await $`git commit -m ${`chore: update ${name} to ${Script.version}`}`.cwd(outdir)
await $`git push origin master`.cwd(outdir)
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "homebrew",
version: Script.version,
metadata: { package: `anomalyco/tap/${name}` },
})
-4
View File
@@ -149,10 +149,6 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
await $`bun ./script/publish-homebrew.ts ${dryRun ? ["--dry-run"] : []}`.env({
...process.env,
OPENCODE_CLI_DIST: root,
})
}
async function archive(bin: string, target: string, binary: string, directory: string) {
+7 -20
View File
@@ -7,7 +7,7 @@ import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "brew"] as const
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
@@ -96,13 +96,6 @@ const make = Effect.gen(function* () {
process.platform === "win32" ? "opencode.exe" : "opencode",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
if (
["opencode-beta", "opencode-v2"].some((name) =>
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
)
)
return "brew"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
@@ -120,7 +113,7 @@ const make = Effect.gen(function* () {
})
const removal = (method: Method) => {
if (method === "curl" || method === "brew" || !installedPackage) return undefined
if (method === "curl" || !installedPackage) return undefined
const commands = {
npm: ["npm", "uninstall", "--global", installedPackage],
pnpm: ["pnpm", "remove", "--global", installedPackage],
@@ -140,12 +133,11 @@ const make = Effect.gen(function* () {
}
}
const release = Effect.fnUntraced(function* (method?: Method) {
const distribution = method === "brew" ? "homebrew" : "npm"
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`,
{
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
@@ -161,11 +153,7 @@ const make = Effect.gen(function* () {
return { package: data.metadata.package, version: data.version }
})
const latest = () =>
method().pipe(
Effect.flatMap(release),
Effect.map((data) => data.version),
)
const latest = () => release().pipe(Effect.map((data) => data.version))
const temporaryDirectory = (prefix: string) =>
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
@@ -175,12 +163,12 @@ const make = Effect.gen(function* () {
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName = (yield* release(method)).package
const packageName = (yield* release()).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
}
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
@@ -214,7 +202,6 @@ const make = Effect.gen(function* () {
if (download.code !== 0) return download
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
return yield* exec(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
+1 -1
View File
@@ -50,7 +50,7 @@ describe("upgrade command", () => {
})
test("rejects unsupported methods before attempting an upgrade", async () => {
const result = await cli(["--method", "apt"])
const result = await cli(["--method", "brew"])
expect(result.exitCode).not.toBe(0)
expect(result.events).toEqual([])
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+10 -14
View File
@@ -456,10 +456,6 @@ export type V2EventServerConnected = {
data: {}
}
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
@@ -1660,19 +1656,19 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ModelVariant = {
id: string
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
@@ -1902,7 +1898,7 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ModelSettings
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
@@ -2111,13 +2107,13 @@ export type ConfigEntry =
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigModelSettings
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigModelSettings
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+7 -3
View File
@@ -15,7 +15,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Source and execution model
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
TypeScript-only syntax is rejected rather than stripped before execution.
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
@@ -40,6 +41,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
default in a `for...of` head and Unicode-escaped keywords.
## Values and literals
@@ -491,8 +494,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
short orientation to the supported subset; this matrix is the full reference.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
+9 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.8",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
@@ -19,6 +19,12 @@
"exports": {
".": "./src/index.ts"
},
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit",
@@ -26,7 +32,8 @@
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:"
"effect": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+7 -9
View File
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports?: Record<string, Record<string, string>>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
@@ -41,14 +41,12 @@ try {
]
}),
)
if (pkg.imports) {
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
}
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
+14 -1
View File
@@ -1,10 +1,14 @@
import { parse, type Program } from "acorn"
import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createBuiltins } from "./intrinsics.js"
import { PendingThrow } from "./model.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -106,7 +110,16 @@ export const executeProgram = <R>(
}
const parseProgram = (code: string): Program => {
return parse(code, {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
const bodyEnd = transpiled.outputText.lastIndexOf("}")
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
return parse(executableCode, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
@@ -0,0 +1,25 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -0,0 +1,11 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
+7 -13
View File
@@ -15,12 +15,6 @@ const error = async (code: string) => {
return result.error
}
describe("source syntax", () => {
test("rejects TypeScript-only syntax", async () => {
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
})
})
describe("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
@@ -107,7 +101,7 @@ describe("host errors escaping built-ins", () => {
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -115,13 +109,13 @@ describe("host errors escaping built-ins", () => {
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 10)",
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 1, col 8)",
"(line 2, col 5)",
)
})
@@ -134,7 +128,7 @@ describe("host errors escaping built-ins", () => {
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
})
})
@@ -153,7 +147,7 @@ describe("call depth", () => {
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
})
test("the limit is 10000 nested calls", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.8",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.8",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+8 -8
View File
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
import { ProviderShared } from "@opencode/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, RuntimeInfo } from "./model.js"
import type { ID, Info } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export interface SDKEvent {
readonly model: RuntimeInfo
readonly model: Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: RuntimeInfo
readonly model: Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: RuntimeInfo, pkg: string) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.canonical ?? model.providerID,
@@ -182,8 +182,8 @@ export interface Interface {
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
@@ -302,7 +302,7 @@ export const locationLayer = Layer.effect(
}),
)
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const providerID = info.canonical ?? info.providerID
@@ -399,7 +399,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
function mapBodyToProviderOptions(model: Info, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
+16 -21
View File
@@ -8,7 +8,6 @@ import { AISDK } from "./aisdk.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
import type { RuntimeInfo } from "./model.js"
import { Npm } from "@opencode/util/npm"
import { Provider } from "./provider.js"
@@ -118,9 +117,9 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means summary compaction. */
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Provider.Compaction
/** Provider transport policy; omitted means HTTP. */
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Provider.Transport
}
@@ -150,7 +149,7 @@ export const withVariant = (
variant
? {
...model,
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
settings: Provider.mergeOverlay(model.settings, variant.settings),
headers: Provider.mergeHeaders(model.headers, variant.headers),
body: Provider.mergeOverlay(model.body, variant.body),
}
@@ -160,11 +159,11 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
}
export const fromCatalogModel = (
model: RuntimeInfo,
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<
@@ -192,7 +191,7 @@ export const fromCatalogModel = (
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: RuntimeInfo,
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
@@ -249,7 +248,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
})
})
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return {
...model,
@@ -261,7 +260,7 @@ function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value |
}
function validateProviderVariables(
model: RuntimeInfo,
model: Info,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
@@ -271,7 +270,7 @@ function validateProviderVariables(
}
function prepareProviderSettings(
model: RuntimeInfo,
model: Info,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
@@ -281,14 +280,14 @@ function prepareProviderSettings(
)
}
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
function unresolvedProviderVariables(model: Info, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
@@ -311,14 +310,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
return { apiKey: credential.access }
}
const unsupported = (model: RuntimeInfo) =>
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
@@ -360,11 +359,7 @@ export const layer = Layer.effect(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const selectedVariant = yield* withVariant(selected, variant)
const runtimeInfo: RuntimeInfo = {
...selectedVariant,
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
}
const runtimeInfo = yield* withVariant(selected, variant)
const model = yield* fromCatalogModel(runtimeInfo, credential, {
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
@@ -387,7 +382,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: provider?.settings?.transport,
transport: runtimeInfo.settings?.transport,
}
})
return Service.of({
@@ -411,7 +406,7 @@ export const layer = Layer.effect(
}),
)
function hasConfiguredAuth(model: RuntimeInfo) {
function hasConfiguredAuth(model: Info) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
+1 -7
View File
@@ -36,9 +36,6 @@ export type Ref = typeof Ref.Type
export const Info = Model.Info
export type Info = Model.Info
/** Effective provider and model settings used only while constructing a runtime model. */
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
export type MutableInfo = DeepMutable<Info>
export { Event } from "@opencode/schema/model"
@@ -191,10 +188,7 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
),
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
+18 -15
View File
@@ -7,6 +7,7 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
@@ -139,18 +140,16 @@ export const AzurePlugin = define({
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
if (resourceName)
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
})
yield* ctx.model.transform((models) => {
@@ -168,6 +167,10 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
})
}
}
@@ -239,9 +242,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
if (settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
+6 -6
View File
@@ -254,13 +254,10 @@ export const OpenAIPlugin = define({
yield* ctx.provider.transform((providers) => {
const item = providers.get(Provider.ID.openai)
if (!item) return
const account = chatgpt?.metadata?.accountID
if (!chatgpt) return
const account = chatgpt.metadata?.accountID
providers.update(item.provider.id, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
...(chatgpt ? { baseURL: codexBaseURL } : {}),
})
if (!chatgpt) return
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
provider.headers = Provider.mergeHeaders(provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
@@ -273,6 +270,9 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+7 -6
View File
@@ -95,13 +95,14 @@ export const XAIPlugin = define({
editor.method.update(device(ctx.app))
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.provider.transform((providers) => {
if (!providers.get(providerID)) return
providers.update(providerID, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
})
})
}
})
}),
})
-5
View File
@@ -134,16 +134,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, CORE_KEYS)
}
export function modelSettings(settings: Settings | undefined) {
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
}
export function mergeOverlay(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
+4 -5
View File
@@ -27,15 +27,14 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
...Model.Info.make({
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
Model.Info.make({
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
modelID: Model.ID.make("api-model"),
package: Provider.aisdk(packageName),
settings,
limit: { context: 100, output: 20 },
}),
settings,
})
})
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
+1 -2
View File
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
}),
)
it.effect("keeps settings scoped while resolving request overlay merges", () =>
it.effect("resolves provider and model overlay merges", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -533,7 +533,6 @@ describe("Provider and Model", () => {
})
const model = required(yield* models.get(providerID, modelID))
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
+11 -19
View File
@@ -37,7 +37,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
const providers = yield* Provider.Service
yield* addPlugin([
new Document({
type: "document",
@@ -64,7 +63,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -75,9 +73,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("keeps the provider websocket policy out of model settings", () =>
it.effect("inherits the provider websocket policy with model overrides", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
yield* addPlugin([
new Document({
@@ -86,12 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
models: {
inherited: {
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
},
},
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -99,9 +92,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
])
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
expect(inherited.settings).toEqual({ shared: "provider", model: true })
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -112,7 +106,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -128,8 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -138,16 +131,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model } },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
},
},
}),
}),
])
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
}),
)
}
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
),
)
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
@@ -472,8 +472,9 @@ describe("AzurePlugin", () => {
yield* addPlugin()
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.settings?.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
}
@@ -205,8 +205,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(provider.settings?.transport).toBe("websocket")
expect(model.settings?.transport).toBeUndefined()
expect(model.settings?.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -83,10 +83,8 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const provider = yield* providers.get(providerID)
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(provider?.settings?.transport).toBe("websocket")
expect(model?.settings?.transport).toBeUndefined()
expect(model?.settings?.transport).toBe("websocket")
}),
)
})
-15
View File
@@ -42,19 +42,4 @@ describe("Provider", () => {
reasoningEffort: "high",
})
})
test("inherits shared and loose settings without provider-only policies", () => {
expect(
Provider.modelSettings({
timeout: 60_000,
chunkTimeout: 30_000,
transport: "websocket",
compaction: { type: "native" },
reasoningEffort: "high",
}),
).toEqual({
compaction: { type: "native" },
reasoningEffort: "high",
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+6 -134
View File
@@ -3,7 +3,7 @@
//
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
// [--trace] [--out <dir>] [--home <dir>]
//
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
@@ -21,15 +21,7 @@
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
//
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
// without activating it the moment it exists, then records when the window's pixels first differ
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
import { execFileSync, spawn } from "node:child_process"
import { spawn } from "node:child_process"
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { createServer } from "node:net"
import { tmpdir } from "node:os"
@@ -53,9 +45,6 @@ const args = parseArgs({
"profile-renderer": { type: "boolean", default: false },
trace: { type: "boolean", default: false },
"settle-ms": { type: "string", default: "1500" },
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
"window-at": { type: "string" },
},
allowPositionals: true,
})
@@ -123,12 +112,10 @@ const probe = `(() => ({
origin: performance.timeOrigin,
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
prepaint: !!document.getElementById('oc-prepaint'),
visible: document.visibilityState === 'visible',
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
rows: document.querySelectorAll('#root [data-timeline-row]').length,
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
rows: document.querySelectorAll('[data-timeline-row]').length,
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
url: location.pathname + location.search,
}))()`
// Main-process bootstrap timing, read after the run: when the process was created, when Node
@@ -250,8 +237,6 @@ type Probe = {
origin: number
firstPaint?: number
domInteractive?: number
prepaint: boolean
visible: boolean
shell: boolean
editor: boolean
rows: number
@@ -288,7 +273,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
const trace = args.values.trace && run === runs
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
const launchArgs = [
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
`--remote-debugging-port=${cdpPort}`,
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
...(trace
@@ -300,12 +284,10 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
]
: []),
]
const raiser = await windowRaiser()
const spawnAt = Date.now()
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
child.unref()
appPid = child.pid
raiser.raise(child.pid!)
let mainProfile: Promise<unknown> | undefined
if (profile) mainProfile = profileMain(spawnAt)
@@ -327,7 +309,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
const seen: Record<string, number> = {}
let last: Probe | undefined
let prepaintSeen = false
let quietSince: number | undefined
let taskMs = 0
let scriptMs = 0
@@ -337,9 +318,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
last = result.result?.result?.value as Probe | undefined
const t = Date.now() - spawnAt
if (last?.prepaint) prepaintSeen = true
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
@@ -378,7 +356,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
cdp.close()
const processes = appPid ? await processTree(appPid) : []
const screenChanges = await raiser.screen()
const boot = profile ? undefined : await mainBootTiming()
await sleep(300)
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
@@ -408,16 +385,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
domInteractive:
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
prepaintVisible:
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
? Math.round(origin + last.firstPaint)
: undefined,
...seen,
// First sampled screen change inside the window: the ground truth for "the user sees something".
screenPainted: screenChanges.changed[0],
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
screenSettled: screenChanges.settled,
rendererIdle: rendererIdleMs,
},
final: { url: last?.url, timelineRows: last?.rows },
@@ -483,22 +451,6 @@ function prepareHome() {
})
}
mkdirSync(paths.logs, { recursive: true })
const at = args.values["window-at"]?.split(",").map(Number)
if (at?.length === 2 && existsSync(userData)) {
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
const bounds = displayAt(at[0], at[1])
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
}
}
}
function displayAt(x: number, y: number) {
if (process.platform !== "win32") return undefined
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
}
async function freePort() {
@@ -613,86 +565,6 @@ function mainLog() {
}
// Working set of every process in the launched app's tree once it is idle.
// Windows places a window launched from a background process behind the foreground one, and
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
// what else is on screen. A helper started before the app polls for its main window and pins it
// topmost without activating it, so the user keeps their focus and the bench window is visible.
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
const script = join(outDir, "raise-window.ps1")
writeFileSync(
script,
[
`Add-Type -AssemblyName System.Drawing`,
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
`[Console]::Out.WriteLine("ready")`,
`$target = [int][Console]::In.ReadLine()`,
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
`$h = 0`,
`for ($i = 0; $i -lt 600; $i++) {`,
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
` Start-Sleep -Milliseconds 10`,
`}`,
`if (-not $h -or $h -eq 0) { exit }`,
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
// colour, a splash and the interface apart.
`$r = New-Object Bench.User32+RECT`,
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
` $t = $clock.ElapsedMilliseconds`,
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
` $sum = 0`,
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
` $g.Dispose(); $bmp.Dispose()`,
` Start-Sleep -Milliseconds 30`,
`}`,
].join("\n"),
)
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
const lines: string[] = []
let buffer = ""
await new Promise<void>((resolve) => {
helper.stdout!.on("data", (chunk: Buffer) => {
buffer += chunk.toString()
const parts = buffer.split(/\r?\n/)
buffer = parts.pop() ?? ""
for (const line of parts) {
if (line === "ready") resolve()
else lines.push(line)
}
})
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
helper.on("exit", () => resolve())
})
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
return {
raise: (pid) => helper.stdin!.write(`${pid}\n`),
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
// content differed from the first sample, i.e. when something other than the background colour
// was on screen.
screen: async () => {
await exited
const samples = lines
.filter((line) => line.startsWith("screen "))
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
const first = samples[0]?.[1]
const last = samples.at(-1)?.[1]
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
// The last sample that still differed from the final content, i.e. when the window stopped changing.
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
},
}
}
async function processTree(root: number) {
const script =
process.platform === "win32"
@@ -1,35 +0,0 @@
import { expect, test } from "bun:test"
import { listInstalledWslDistros, probeWslRuntime, runWslSh, wslArgs } from "./runtime"
test("wslArgs bypasses the distro default shell", () => {
expect(wslArgs(["sh", "-lc", 'printf "%s\\n" "$HOME"'], "Debian")).toEqual([
"-d",
"Debian",
"--exec",
"sh",
"-lc",
'printf "%s\\n" "$HOME"',
])
expect(wslArgs(["bash", "-se"], "Debian", "root")).toEqual([
"-d",
"Debian",
"--user",
"root",
"--exec",
"bash",
"-se",
])
})
// Exercise the real wsl.exe argv path: with `--`, WSL routes the command line through the distro's
// default shell, which expands `$cli` to "" before `sh` runs the script (#48640).
const distro =
process.platform === "win32" && (await probeWslRuntime()).available
? (await listInstalledWslDistros().catch(() => [])).find((item) => item.isDefault)?.name
: undefined
test.skipIf(!distro)("inline scripts keep their own variable expansion inside WSL", async () => {
const result = await runWslSh(['cli="from-script"', 'printf "%s\\n" "$cli"'].join("\n"), distro)
expect(result.code).toBe(0)
expect(result.stdout.trim()).toBe("from-script")
})
+1 -3
View File
@@ -38,9 +38,7 @@ const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
// `--` hands the command line to the distro's default shell, which expands `$VAR` and `$(...)`
// inside our inline scripts before `sh -lc` ever runs them. `--exec` runs the command directly.
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--exec", ...args]
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
}
export function runWsl(args: string[], opts: RunWslOptions = {}) {
+30 -33
View File
@@ -78,15 +78,11 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
const ssh = useSsh()
const sshConnections = createSshConnections(props.api.sshServers)
const language = useLanguage()
const ready = createMemo(
() =>
!firstLaunch.loading &&
!defaultServer.loading &&
!sidecar.loading &&
!locale.loading &&
!wslServers.isLoading &&
!ssh.loading,
)
// The shell mounts from local state as soon as the quick lookups resolve. The local service,
// WSL and SSH connections are discovered meanwhile and appear in the server list when ready;
// until then the list is marked pending so nothing acts on it being empty.
const ready = createMemo(() => !firstLaunch.loading && !defaultServer.loading && !locale.loading)
const serversPending = createMemo(() => sidecar.loading || wslServers.isLoading || ssh.loading)
const servers = createMemo(() => {
const data = initializationData(sidecar)
const list: ServerConnection.Any[] = []
@@ -109,30 +105,31 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
return (
<Show when={ready()}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
serverKey={key}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
</Show>
</Suspense>
</AppInterface>
)}
</Show>
<AppInterface
defaultServer={effectiveDefaultServer()}
servers={servers()}
serversPending={serversPending()}
router={router}
>
<DesktopStartupReady
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
serverKey={effectiveDefaultServer()}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
</Show>
</Suspense>
</AppInterface>
</Show>
)
}
@@ -1,5 +1,5 @@
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
import { createResource } from "solid-js"
import { createEffect, createResource, createRoot } from "solid-js"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
@@ -24,6 +24,16 @@ export function DesktopFirstLaunchOnboarding(props: {
if (!props.pending) return
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
// The shell mounts before the local service has connected; the decision needs the full list.
await new Promise<void>((resolve) =>
createRoot((dispose) =>
createEffect(() => {
if (server.pending) return
dispose()
resolve()
}),
),
)
const shouldTrigger =
props.initialUrl === "/" &&
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.8",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.8",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.8",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"scripts": {
+4 -39
View File
@@ -13251,27 +13251,6 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13291,7 +13270,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13314,7 +13293,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15529,7 +15508,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15635,20 +15614,6 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15656,7 +15621,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+2 -16
View File
@@ -17,14 +17,6 @@ export const Settings = Schema.StructWithRest(
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
export const ModelSettings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Model.Settings" })
export type ModelSettings = typeof ModelSettings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
@@ -33,12 +25,6 @@ export const Overlays = {
body: JsonRecord.pipe(optional),
}
const ModelOverlays = {
settings: ModelSettings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
headers: Overlays.headers,
body: Overlays.body,
@@ -71,11 +57,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...ModelOverlays,
...Overlays,
capabilities: Capabilities.pipe(optional),
variants: Schema.Struct({
id: VariantID,
...ModelOverlays,
...Overlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
+2 -17
View File
@@ -56,21 +56,6 @@ export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tok
})
export type MaxTokensField = typeof MaxTokensField.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
}),
// Provider packages may define arbitrary model-level options beyond OpenCode's shared compaction policy.
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Model.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
@@ -112,7 +97,7 @@ export const Cost = Schema.Struct({
export interface Variant extends Schema.Schema.Type<typeof Variant> {}
export const Variant = Schema.Struct({
id: VariantID,
...Overlays,
...Provider.Overlays,
}).annotate({ identifier: "Model.Variant" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
@@ -125,7 +110,7 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
...Overlays,
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
time: Schema.Struct({
@@ -169,7 +169,6 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.ModelSettings,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
@@ -184,7 +183,6 @@ describe("contract hygiene", () => {
Model.Ref,
Model.Capabilities,
Model.Cost,
Model.Settings,
Model.Variant,
Project.Current,
Worktree.Directory,
@@ -241,12 +239,11 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts" && item.file !== "model.ts" && item.file !== "integration.ts")
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "model.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(source).not.toContain("Schema.mutable")
})
+7 -4
View File
@@ -75,10 +75,13 @@ describe("Model.Info", () => {
})
})
describe("Model.Settings", () => {
test("preserves provider-specific model options", () => {
expect(Schema.decodeUnknownSync(Model.Settings)({ providerOption: true })).toEqual({
providerOption: true,
describe("Model.Capabilities", () => {
test("decodes the optional transport setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.8",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+5 -5
View File
@@ -120,7 +120,7 @@ const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, lev
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
if (status.attention) return theme.hue.accent[200]
if (status.attention) return theme.hue.interactive[800]
if (status.unread === "error") return theme.text.feedback.error.base
return undefined
}
@@ -528,8 +528,8 @@ function VerticalSessionTabs(props: {
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
const tooltipWidth = () => Math.min(54, dimensions().width - width())
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.hue.accent[200]
const activeNumber = () => theme.hue.interactive[200]
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.base, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.base, 0.05))
@@ -1291,8 +1291,8 @@ function HorizontalSessionTabs(props: {
onCleanup(clearCloseHold)
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const unreadColor = () => theme.hue.accent[200]
const activeNumber = () => theme.hue.interactive[200]
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const idleNumber = () => tint(theme.text.formfield.base, theme.background.base, 0.55)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
@@ -32,7 +32,7 @@ export default Plugin.define({
return (
<Show when={pending() > 0}>
<box flexShrink={0}>
<Spinner color={theme.hue.interactive[200]}>/btw</Spinner>
<Spinner color={theme.hue.accent[800]}>/btw</Spinner>
</box>
</Show>
)
@@ -181,7 +181,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.hue.interactive[200]}
color={theme.hue.accent[800]}
/>
</box>
)}
@@ -236,7 +236,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.hue.interactive[200]}
color={theme.hue.accent[800]}
/>
<text fg={theme.text.base}> esc stop</text>
</box>
@@ -284,10 +284,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ text: "", color: theme.text.base },
)
if (status.attention === "question")
lines.push({ text: "? Which approach should I take?", color: theme.hue.accent[200] })
lines.push({ text: "? Which approach should I take?", color: theme.hue.interactive[800] })
else if (status.attention === "permission")
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.accent[200] })
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.interactive[200] })
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.interactive[800] })
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.accent[800] })
else if (outcome === "failed")
lines.push({
text: `✗ bun run test failed — 3 tests failing in ${file}`,
+3 -3
View File
@@ -142,9 +142,9 @@ function map(
formfieldFocusedBg: exact(theme.background.formfield.focused),
formfieldFocusedText: exact(theme.text.formfield.focused),
selection: exact(theme.text.formfield.selected),
running: exact(theme.hue.interactive[200]),
question: exact(theme.hue.accent[200]),
permission: exact(theme.hue.accent[200]),
running: exact(theme.hue.accent[800]),
question: exact(theme.hue.interactive[800]),
permission: exact(theme.hue.interactive[800]),
success: exact(theme.text.feedback.success.base),
link: exact(theme.markdown.link),
categorical: dedupeWith(
@@ -139,7 +139,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
expect(indicatorColor()?.toInts()).toEqual(theme.hue.accent[200].toInts())
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
const glow = () => {
const colors = app
.captureSpans()
@@ -156,7 +156,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
expect(full).toBeGreaterThan(0)
setActive("first")
await app.renderOnce()
expect(indicatorColor()?.toInts()).toEqual(theme.hue.accent[200].toInts())
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
@@ -172,7 +172,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.base : theme.hue.accent[200]).toInts(),
(unread === "error" ? theme.text.feedback.error.base : theme.hue.interactive[800]).toInts(),
)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
+3 -3
View File
@@ -90,9 +90,9 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
formfieldFocusedBg: theme.background.formfield.focused,
formfieldFocusedText: theme.text.formfield.focused,
selection: theme.text.formfield.selected,
running: theme.hue.interactive[200],
question: theme.hue.accent[200],
permission: theme.hue.accent[200],
running: theme.hue.accent[800],
question: theme.hue.interactive[800],
permission: theme.hue.interactive[800],
success: theme.text.feedback.success.base,
link: theme.markdown.link,
shade: theme.background.raised.base,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1
View File
@@ -296,6 +296,7 @@ export default defineConfig({
],
components: {
Hero: "./src/components/Hero.astro",
Banner: "./src/components/Banner.astro",
Head: "./src/components/Head.astro",
Header: "./src/components/Header.astro",
Footer: "./src/components/Footer.astro",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.10",
"version": "2.0.8",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+61
View File
@@ -0,0 +1,61 @@
<a class="v2-banner" href="https://opencode.ai/v2">
<span class="badge">New</span>
<span class="text">OpenCode v2 is now available</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</a>
<style>
.v2-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 24px;
background: var(--color-background-interactive);
color: var(--color-text-strong) !important;
border-bottom: 1px solid var(--color-border-weak);
text-decoration: none;
font-size: 14px;
line-height: 1;
transition: background 0.15s ease;
}
.v2-banner:hover {
background: var(--color-background-interactive-weaker);
}
.badge {
padding: 4px 8px;
background: var(--color-background-strong);
color: var(--color-text-inverted);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
border-radius: 2px;
}
.text {
font-weight: 500;
}
.arrow {
color: var(--color-text);
transition: transform 0.15s ease;
}
.v2-banner:hover .arrow {
transform: translateX(4px);
}
@media (max-width: 30rem) {
.v2-banner {
padding: 10px 16px;
gap: 8px;
}
.badge {
display: none;
}
}
</style>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.10",
"version": "2.0.8",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.10",
"version": "2.0.8",
"private": true,
"type": "module",
"scripts": {
+4 -39
View File
@@ -13251,27 +13251,6 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13291,7 +13270,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13314,7 +13293,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15529,7 +15508,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15635,20 +15614,6 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15656,7 +15621,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
+4 -39
View File
@@ -13251,27 +13251,6 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13291,7 +13270,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -13314,7 +13293,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Model.Settings"
"$ref": "#/components/schemas/Config.Provider.Settings"
},
"headers": {
"type": "object",
@@ -15529,7 +15508,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
@@ -15635,20 +15614,6 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15656,7 +15621,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Model.Settings"
"$ref": "#/components/schemas/Provider.Settings"
},
"headers": {
"type": "object",
+2 -1
View File
@@ -506,6 +506,7 @@ headers, and model variants.
}
```
Provider `settings.transport: "websocket"` selects its session WebSocket.
`settings.transport: "websocket"` on a provider or model selects its session
WebSocket; a model value overrides the provider value.
See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration.
+6 -2
View File
@@ -558,6 +558,9 @@ sessions.
"providers": {
"openai": {
"settings": { "transport": "http" },
"models": {
"gpt-5.5": { "settings": { "transport": "websocket" } },
},
},
},
}
@@ -565,8 +568,9 @@ sessions.
WebSocket behavior follows these rules:
- Built-in providers opt supported routes in according to their own policy.
- Provider `settings.transport: "websocket"` enables it; `"http"` disables it.
- Built-in providers opt supported models in according to their own policy.
- `settings.transport: "websocket"` enables a provider or model; `"http"` disables it.
- A model value overrides its provider value.
- `"websocket"` on a route without a WebSocket channel logs a warning and falls back to HTTP.
- OpenAI provider compaction uses the same connection.
- xAI continues from stored responses only. With its default `store: false`, each step is sent in full over the reused