feat(desktop): compose V2 sidecar

This commit is contained in:
LukeParkerDev 2026-07-28 15:35:56 +10:00
commit 850721c3e9
28 changed files with 577 additions and 161 deletions

View file

@ -308,6 +308,7 @@ jobs:
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
bun_install_flags: --os=win32 --cpu=arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win
@ -388,6 +389,7 @@ jobs:
working-directory: packages/desktop
env:
NODE_OPTIONS: --max-old-space-size=4096
RUST_TARGET: ${{ matrix.settings.target }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}

View file

@ -174,12 +174,14 @@
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"ws": "8.21.0",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/ws": "8.18.1",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
@ -431,10 +433,28 @@
"devDependencies": {
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk-next": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@ -452,22 +472,6 @@
"vite": "catalog:",
"zod-openapi": "5.4.6",
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
},
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
@ -650,6 +654,7 @@
"packages/sdk-next": {
"name": "@opencode-ai/sdk-next",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@ -663,6 +668,8 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"ws": "8.21.0",
},
},
"packages/server": {
@ -1039,23 +1046,23 @@
},
},
"trustedDependencies": [
"esbuild",
"protobufjs",
"electron",
"web-tree-sitter",
"esbuild",
"electron",
"protobufjs",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@opentui/core": "catalog:",

View file

@ -25,5 +25,6 @@ dist-ssr
out/
resources/opencode-cli*
resources/server-assets
resources/icons
resources/*.metainfo.xml

View file

@ -46,3 +46,20 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
expect(desktop).toContain("NoDisplay=true")
})
test("packages server native assets outside ASAR", async () => {
const module = await import("./electron-builder.config")
const config = module.default
const resources = Array.isArray(config.extraResources) ? config.extraResources : []
expect(config.files).toContain("!resources/server-assets/**/*")
expect(resources).toContainEqual({ from: "resources/server-assets/", to: "server-assets/" })
})
test("keeps target native variants out of packaged production dependencies", async () => {
const pkg = await Bun.file("package.json").json()
expect(pkg.optionalDependencies).toBeUndefined()
expect(pkg.devDependencies["@lydell/node-pty-win32-arm64"]).toBe("1.2.0-beta.12")
expect(pkg.devDependencies["@parcel/watcher-win32-arm64"]).toBe("2.5.1")
})

View file

@ -52,13 +52,17 @@ const getBase = (appId: string): Configuration => ({
extraMetadata: {
desktopName: `${appId}.desktop`,
},
files: ["out/**/*", "resources/**/*"],
files: ["out/**/*", "resources/**/*", "!resources/server-assets/**/*"],
extraResources: [
{
from: "native/",
to: "native/",
filter: ["index.js", "index.d.ts", "build/Release/mac_window.node", "swift-build/**"],
},
{
from: "resources/server-assets/",
to: "server-assets/",
},
],
mac: {
category: "public.app-category.developer-tools",

View file

@ -0,0 +1,12 @@
import { expect, test } from "bun:test"
import config from "./electron.vite.config"
test("bundles the Node client into the packaged main process", () => {
expect(config.main?.build?.externalizeDeps).toEqual({ exclude: ["@opencode-ai/client"] })
})
test("keeps the bundled Node client out of packaged production dependencies", async () => {
const pkg = await Bun.file("package.json").json()
expect(pkg.dependencies["@opencode-ai/client"]).toBeUndefined()
expect(pkg.devDependencies["@opencode-ai/client"]).toBe("workspace:*")
})

View file

@ -1,9 +1,8 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
import { serverVite } from "@opencode-ai/sdk-next/vite"
import { getServerTarget } from "./scripts/target"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@ -12,7 +11,8 @@ const channel = (() => {
return "dev"
})()
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const server = serverVite()
const target = getServerTarget()
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
@ -33,12 +33,18 @@ const sentry =
export default defineConfig({
main: {
resolve: server.resolve,
define: {
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
OPENCODE_LIBC: target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
},
build: {
target: "node24",
externalizeDeps: { exclude: ["@opencode-ai/client"] },
rollupOptions: {
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
external: [/^@opencode-ai\/simulation(?:\/|$)/],
// Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can
// corrupt bundled TypeScript, while a Rollup banner places the shim safely.
output: {
@ -51,33 +57,8 @@ const require = __cjs_mod__.createRequire(import.meta.url);
`,
},
},
externalizeDeps: { include: [nodePtyPkg] },
},
plugins: [
{
name: "opencode:node-pty-narrower",
enforce: "pre",
resolveId(s) {
if (s === "@lydell/node-pty") return nodePtyPkg
},
},
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
async writeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (!l.endsWith(".wasm")) continue
await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
plugins: server.plugins,
},
preload: {
build: {

View file

@ -11,6 +11,7 @@
},
"scripts": {
"typecheck": "tsgo -b",
"test": "bun test --only-failures",
"predev": "bun ./scripts/predev.ts",
"dev": "electron-vite dev",
"prebuild": "bun ./scripts/prebuild.ts",
@ -36,8 +37,26 @@
"devDependencies": {
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk-next": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
@ -56,21 +75,5 @@
"typescript": "~5.6.2",
"vite": "catalog:",
"zod-openapi": "5.4.6"
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
}

View file

@ -7,4 +7,4 @@ const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
await $`cd ../opencode && bun script/build-node.ts`
await import("./stage-server-assets")

View file

@ -2,4 +2,4 @@ import { $ } from "bun"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await $`cd ../opencode && bun script/build-node.ts`
await import("./stage-server-assets")

View file

@ -1,8 +1,6 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
await import("./prebuild")
const pkg = await Bun.file("./package.json").json()
pkg.version = Script.version
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")

View file

@ -0,0 +1,36 @@
import { chmod, cp, mkdir, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getServerTarget } from "./target"
const target = getServerTarget()
const root = path.resolve(import.meta.dirname, "../resources/server-assets")
const nodePtyPackage = `@lydell/node-pty-${target.platform}-${target.arch}`
const parcelWatcherPackage = `@parcel/watcher-${target.platform}-${target.arch}${target.platform === "linux" ? "-glibc" : ""}`
const photonWasm = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
const nodePtyEntry = fileURLToPath(import.meta.resolve(nodePtyPackage))
const nodePtyRoot = path.resolve(path.dirname(nodePtyEntry), "..")
await rm(root, { recursive: true, force: true })
await mkdir(root, { recursive: true })
await cp(nodePtyRoot, path.join(root, nodePtyPackage), {
recursive: true,
filter: (source) => !source.endsWith(".map") && !source.endsWith(".pdb"),
})
await mkdir(path.join(root, parcelWatcherPackage), { recursive: true })
await cp(
fileURLToPath(import.meta.resolve(parcelWatcherPackage)),
path.join(root, parcelWatcherPackage, "watcher.node"),
)
await mkdir(path.join(root, path.dirname(photonWasm)), { recursive: true })
await cp(fileURLToPath(import.meta.resolve(photonWasm)), path.join(root, photonWasm))
if (target.platform === "darwin") {
await chmod(path.join(root, nodePtyPackage, "prebuilds", `darwin-${target.arch}`, "spawn-helper"), 0o755)
}
await writeFile(
path.join(root, "target.json"),
`${JSON.stringify({ ...target, nodePtyPackage, parcelWatcherPackage }, null, 2)}\n`,
)

View file

@ -0,0 +1,72 @@
export const SIDECAR_BINARIES: Array<{
rustTarget: string
ocBinary: string
assetExt: string
platform: "darwin" | "linux" | "win32"
arch: "arm64" | "x64"
}> = [
{
rustTarget: "aarch64-apple-darwin",
ocBinary: "opencode-darwin-arm64",
assetExt: "zip",
platform: "darwin",
arch: "arm64",
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
platform: "darwin",
arch: "x64",
},
{
rustTarget: "aarch64-pc-windows-msvc",
ocBinary: "opencode-windows-arm64",
assetExt: "zip",
platform: "win32",
arch: "arm64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
platform: "win32",
arch: "x64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
platform: "linux",
arch: "x64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
ocBinary: "opencode-linux-arm64",
assetExt: "tar.gz",
platform: "linux",
arch: "arm64",
},
]
export const RUST_TARGET = process.env.RUST_TARGET
function nativeTarget() {
if (process.platform === "darwin") return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (process.platform === "win32")
return process.arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"
if (process.platform === "linux")
return process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`)
}
export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = SIDECAR_BINARIES.find((item) => item.rustTarget === target)
if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`)
return binaryConfig
}
export function getServerTarget(target = RUST_TARGET ?? nativeTarget()) {
const current = getCurrentSidecar(target)
return { rustTarget: current.rustTarget, platform: current.platform, arch: current.arch }
}

View file

@ -0,0 +1,28 @@
import { expect, test } from "bun:test"
import { SIDECAR_BINARIES, getServerTarget } from "./target"
import packageManifest from "../package.json"
test("derives the Windows ARM64 server assets from the release target", () => {
expect(getServerTarget("aarch64-pc-windows-msvc")).toEqual({
rustTarget: "aarch64-pc-windows-msvc",
platform: "win32",
arch: "arm64",
})
})
test("derives each packaged server architecture independently of the runner", () => {
expect(getServerTarget("x86_64-apple-darwin")).toMatchObject({ platform: "darwin", arch: "x64" })
expect(getServerTarget("aarch64-unknown-linux-gnu")).toMatchObject({ platform: "linux", arch: "arm64" })
})
test("declares every target-native server dependency directly", () => {
const dependencies = packageManifest.devDependencies
for (const target of SIDECAR_BINARIES) {
expect(dependencies[`@lydell/node-pty-${target.platform}-${target.arch}`]).toBeDefined()
const watcher = `@parcel/watcher-${target.platform}-${target.arch}${target.platform === "linux" ? "-glibc" : ""}`
expect(dependencies[watcher]).toBeDefined()
}
expect(dependencies["@parcel/watcher-darwin-x64"]).toBe("2.5.1")
expect(dependencies["@silvia-odwyer/photon-node"]).toBe("0.3.4")
})

View file

@ -1,5 +1,7 @@
import { $ } from "bun"
export { getCurrentSidecar, getServerTarget, RUST_TARGET, SIDECAR_BINARIES } from "./target"
export type Channel = "dev" | "beta" | "prod"
export function resolveChannel(): Channel {
@ -8,56 +10,6 @@ export function resolveChannel(): Channel {
return "dev"
}
export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [
{
rustTarget: "aarch64-apple-darwin",
ocBinary: "opencode-darwin-arm64",
assetExt: "zip",
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "aarch64-pc-windows-msvc",
ocBinary: "opencode-windows-arm64",
assetExt: "zip",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
ocBinary: "opencode-linux-arm64",
assetExt: "tar.gz",
},
]
export const RUST_TARGET = Bun.env.RUST_TARGET
function nativeTarget() {
const { platform, arch } = process
if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (platform === "win32") return arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"
if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target)
if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`)
return binaryConfig
}
export async function copyBinaryToSidecarFolder(source: string) {
const dir = `resources`
await $`mkdir -p ${dir}`

View file

@ -5,15 +5,3 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View file

@ -47,6 +47,9 @@ export function preferAppEnv(userDataPath: string) {
...(shell ? loadShellEnv(shell, getLogger()) : null),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_NODE_PTY_PATH:
process.env.OPENCODE_NODE_PTY_PATH ??
join(serverAssetsPath(), `@lydell/node-pty-${process.platform}-${process.arch}`, "lib/index.js"),
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
}
@ -132,6 +135,9 @@ export async function spawnLocalServer(
port,
password,
userDataPath: options.userDataPath,
version: app.getVersion(),
channel: import.meta.env.OPENCODE_CHANNEL,
serverAssetsPath: serverAssetsPath(),
})
}).catch((error) => {
if (!exited) child.kill()
@ -166,13 +172,12 @@ export async function spawnLocalServer(
stop: () => {
if (stopping) return stopping
if (exited) return Promise.resolve()
child.postMessage({ type: "stop" })
stopping = Promise.race([
exit.promise.then(() => undefined),
delay(SIDECAR_STOP_TIMEOUT).then(() => {
if (!exited) child.kill()
}),
])
stopping = (async () => {
child.postMessage({ type: "stop" })
if (await settlesWithin(exit.promise, SIDECAR_STOP_TIMEOUT)) return
if (!exited) child.kill()
await exit.promise
})()
return stopping
},
},
@ -180,6 +185,12 @@ export async function spawnLocalServer(
}
}
function serverAssetsPath() {
return app.isPackaged
? join(process.resourcesPath, "server-assets")
: join(app.getAppPath(), "resources/server-assets")
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrls: URL[]
try {
@ -216,8 +227,15 @@ function createSidecarEnv(): Record<string, string> {
return env
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
function settlesWithin(promise: Promise<unknown>, ms: number) {
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), ms)
timeout.unref()
void promise.then(() => {
clearTimeout(timeout)
resolve(true)
})
})
}
function serializeError(error: unknown) {

View file

@ -1,5 +1,6 @@
import * as http from "node:http"
import * as tls from "node:tls"
import path from "node:path"
type NodeHttpWithEnvProxy = typeof http & {
setGlobalProxyFromEnv: () => void
@ -16,6 +17,9 @@ type StartCommand = {
port: number
password: string
userDataPath: string
version: string
channel: string
serverAssetsPath: string
}
type StopCommand = { type: "stop" }
@ -50,18 +54,17 @@ parentPort.on("message", (event) => {
async function start(command: StartCommand) {
try {
prepareSidecarEnv(command.password, command.userDataPath)
prepareSidecarEnv(command)
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Server } = await import("virtual:opencode-server")
const { Server } = await import("@opencode-ai/sdk-next/server")
listener = await Server.listen({
app: { name: "desktop", version: command.version, channel: command.channel },
port: command.port,
hostname: command.hostname,
username: "opencode",
password: command.password,
cors: ["oc://renderer"],
})
parentPort.postMessage({ type: "ready" })
} catch (error) {
@ -80,11 +83,17 @@ async function stop() {
}
}
function prepareSidecarEnv(password: string, userDataPath: string) {
function prepareSidecarEnv(command: StartCommand) {
const nodePtyPackage = `@lydell/node-pty-${process.platform}-${process.arch}`
const parcelWatcherPackage = `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? "-glibc" : ""}`
Object.assign(process.env, {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
OPENCODE_SERVER_PASSWORD: command.password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? command.userDataPath,
OPENCODE_NODE_ASSETS_DIR: command.serverAssetsPath,
OPENCODE_NODE_PTY_PATH: path.join(command.serverAssetsPath, nodePtyPackage, "lib/index.js"),
OPENCODE_PARCEL_WATCHER_PATH: path.join(command.serverAssetsPath, parcelWatcherPackage, "watcher.node"),
OPENCODE_PHOTON_WASM_PATH: path.join(command.serverAssetsPath, "@silvia-odwyer/photon-node/photon_rs_bg.wasm"),
})
}
@ -136,12 +145,18 @@ function parseCommand(value: unknown): SidecarCommand | undefined {
if (typeof command.port !== "number") return
if (typeof command.password !== "string") return
if (typeof command.userDataPath !== "string") return
if (typeof command.version !== "string") return
if (typeof command.channel !== "string") return
if (typeof command.serverAssetsPath !== "string") return
return {
type: "start",
hostname: command.hostname,
port: command.port,
password: command.password,
userDataPath: command.userDataPath,
version: command.version,
channel: command.channel,
serverAssetsPath: command.serverAssetsPath,
}
}

View file

@ -1,7 +1,7 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { createRequire } from "node:module"
import { join } from "node:path"
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
import { wslTerminalArgs } from "./policy"
@ -32,6 +32,7 @@ export type RunWslOptions = {
const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
let nodePty: typeof import("@lydell/node-pty") | undefined
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
@ -112,7 +113,7 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
const child = loadNodePty().spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 24,
@ -172,6 +173,15 @@ function runInteractiveCommand(command: string, args: string[], opts: RunWslOpti
})
}
function loadNodePty() {
if (nodePty) return nodePty
const loaded: typeof import("@lydell/node-pty") = createRequire(import.meta.url)(
process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty",
)
nodePty = loaded
return loaded
}
function createOutputDecoder() {
let decoder: TextDecoder | undefined
return {

View file

@ -5,13 +5,19 @@
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./server": {
"types": "./src/server-interface.ts",
"default": "./src/server.ts"
},
"./vite": "./src/vite.ts"
},
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@ -24,6 +30,8 @@
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"ws": "8.21.0"
}
}

View file

@ -0,0 +1,28 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
Tool: { Error: Tool.Error },
}

View file

@ -0,0 +1,26 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
}

View file

@ -0,0 +1,61 @@
import "./plugin-runtime.promise"
import "./plugin-runtime.effect"
import { registerHooks } from "node:module"
const key = Symbol.for("opencode.sdk-next.plugin-runtime")
const globals = globalThis as typeof globalThis & Record<symbol, unknown>
if (!globals[key]) {
globals[key] = true
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
export const Command = sdk.Command
export const Connection = sdk.Connection
export const Credential = sdk.Credential
export const Integration = sdk.Integration
export const Model = sdk.Model
export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Error = sdk.Tool.Error`
const modules: Record<string, string> = {
"@opencode-ai/plugin": "opencode:plugin-v2",
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
}
const sources: Record<string, string> = {
"opencode:plugin-v2": promiseModule,
"opencode:plugin-promise-plugin": promisePluginModule,
"opencode:plugin-promise-tool": "export {}",
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
}
registerHooks({
resolve(specifier, context, nextResolve) {
const url = modules[specifier]
return url ? { url, shortCircuit: true } : nextResolve(specifier, context)
},
load(url, context, nextLoad) {
const source = sources[url]
return source ? { format: "module", source, shortCircuit: true } : nextLoad(url, context)
},
})
}

View file

@ -0,0 +1,16 @@
export type ListenOptions = {
readonly app: {
readonly name: string
readonly version: string
readonly channel: string
}
readonly hostname: string
readonly port: number
readonly password: string
}
export type Listener = { readonly stop: () => Promise<void> }
export type Listen = (options: ListenOptions) => Promise<Listener>
export declare const listen: Listen
export declare const Server: { readonly listen: Listen }

View file

@ -0,0 +1,79 @@
import "./plugin-runtime"
import { NodeServices } from "@effect/platform-node"
import { ServerProcess } from "@opencode-ai/server/process"
import { Effect, Exit, ManagedRuntime, Scope } from "effect"
import { randomUUID } from "node:crypto"
import type { Listen, ListenOptions } from "./server-interface"
export const listen: Listen = async (options) => {
const runtime = ManagedRuntime.make(NodeServices.layer)
const scope = await runtime.runPromise(Scope.make())
await runtime
.runPromise(
ServerProcess.start(fromEnvironment(options), {
instanceID: randomUUID(),
onListen: () => Effect.succeed(Effect.void),
}).pipe(Effect.provideService(Scope.Scope, scope)),
)
.catch(async (error) => {
await runtime
.runPromise(Scope.close(scope, Exit.fail(error)))
.finally(() => Effect.runPromise(runtime.disposeEffect))
throw error
})
let stopped = false
return {
stop: async () => {
if (stopped) return
stopped = true
await runtime.runPromise(Scope.close(scope, Exit.void)).finally(() => Effect.runPromise(runtime.disposeEffect))
},
}
}
function fromEnvironment(options: ListenOptions, environment = process.env) {
const channel = options.app.channel
return {
...options,
simulation: truthy(environment.OPENCODE_SIMULATE),
database: {
path:
environment.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(channel) || truthy(environment.OPENCODE_DISABLE_CHANNEL_DB)
? "opencode.db"
: `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: environment.OPENCODE_MODELS_URL,
file: environment.OPENCODE_MODELS_PATH,
fetch: !truthy(environment.OPENCODE_DISABLE_MODELS_FETCH),
},
observability: {
endpoint: environment.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: environment.OTEL_EXPORTER_OTLP_HEADERS,
},
config: {
directory: environment.OPENCODE_CONFIG_DIR,
project: !truthy(environment.OPENCODE_CONFIG_PROJECT_DISABLE ?? environment.OPENCODE_DISABLE_PROJECT_CONFIG),
file: environment.OPENCODE_CONFIG,
content: environment.OPENCODE_CONFIG_CONTENT,
},
windows: {
gitbash: environment.OPENCODE_GIT_BASH_PATH,
},
fs: {
filewatcher: !truthy(environment.OPENCODE_FILEWATCHER_DISABLE ?? environment.OPENCODE_DISABLE_FILEWATCHER),
fff:
environment.OPENCODE_DISABLE_FFF === undefined
? process.platform !== "win32"
: !truthy(environment.OPENCODE_DISABLE_FFF),
},
}
}
function truthy(value?: string) {
return value === "1" || value?.toLowerCase() === "true"
}
export const Server = { listen }

View file

@ -0,0 +1,43 @@
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import type { Plugin } from "vite"
export function serverVite() {
const plugins: Plugin[] = [
{
name: "opencode:raw-text",
async load(id) {
if (!id.endsWith(".md")) return
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
},
},
{
name: "opencode:runtime-require",
enforce: "pre",
transform(code, id) {
if (!id.endsWith("turndown/lib/turndown.es.js")) return
const transformed = code.replace(" var domino = require('@mixmark-io/domino');", "")
if (transformed === code) this.error("Failed to rewrite Turndown's Domino require")
return `import domino from "@mixmark-io/domino"\n${transformed}`
},
},
]
return {
plugins,
resolve: {
alias: [
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
{ find: /^solid-js$/, replacement: "solid-js/dist/solid.js" },
{
find: /^ws$/,
replacement: path.join(
path.dirname(createRequire(import.meta.url).resolve("ws/package.json")),
"wrapper.mjs",
),
},
],
conditions: ["node"],
},
}
}

View file

@ -8,19 +8,26 @@ const core = resolve(import.meta.dir, "../../core")
const server = resolve(import.meta.dir, "../../server")
test("bundles the client and in-memory host", async () => {
const inputs = await bundleInputs()
const inputs = await bundleInputs("@opencode-ai/sdk-next")
expect(within(inputs, client).length).toBeGreaterThan(0)
expect(within(inputs, core).length).toBeGreaterThan(0)
expect(within(inputs, server).length).toBeGreaterThan(0)
})
async function bundleInputs() {
test("declares the Node server composition entrypoint", async () => {
const inputs = await bundleInputs("@opencode-ai/sdk-next/server")
expect(within(inputs, core).length).toBeGreaterThan(0)
expect(within(inputs, server).length).toBeGreaterThan(0)
})
async function bundleInputs(specifier: string) {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"')
await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
const child = Bun.spawn(
[
process.execPath,

View file

@ -28,6 +28,10 @@
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/desktop#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/ui#test": {
"dependsOn": ["^build"],
"outputs": []