feat(sdk): add Promise-first embedded host (#44746)

This commit is contained in:
Kit Langton 2026-08-24 15:26:05 -04:00 committed by GitHub
parent 563943c52e
commit c19186ee54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 746 additions and 295 deletions

View file

@ -1,5 +1,6 @@
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
import { Effect, Schema, SchemaAST, Stream } from "effect"
import type { Scope } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import type { Context, Plugin } from "./plugin.js"
@ -84,12 +85,11 @@ export function fromPromise(plugin: Plugin) {
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const scope = yield* Scope.Scope
const context = yield* Effect.context<Scope.Scope>()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
Effect.runPromiseWith(context)(effect).then((registration) => ({
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
@ -325,9 +325,10 @@ export function fromPromise(plugin: Plugin) {
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
if (!cleanup) return
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
yield* Effect.acquireRelease(
Effect.promise(() => Promise.resolve(plugin.setup(context2))),
(cleanup) => (cleanup ? Effect.promise(() => Promise.resolve(cleanup())) : Effect.void),
)
}),
})
}

View file

@ -1,50 +1,72 @@
# @opencode-ai/sdk
Effect-native scoped OpenCode host for in-process applications.
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
In-process OpenCode host for Promise and Effect applications. The SDK executes Server's assembled HTTP router in memory, opening no listener and adding no network hop.
```ts
import { OpenCode } from "@opencode-ai/sdk"
await using opencode = await OpenCode.create()
const session = await opencode.sessions.create({
location: { directory: "/workspace" },
})
```
Pass imported Promise plugins in `plugins`, or register one later with `await opencode.plugin(plugin)`.
The Promise API uses the same values, errors, request options, and `AsyncIterable` streams as `@opencode-ai/client`.
Embedded hosts are silent by default. Set `log` to receive structured log entries:
```ts
await using opencode = await OpenCode.create({
log: {
level: "warn",
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
},
})
```
`close()` and `Symbol.asyncDispose` release router resources, Location services, fibers, and scoped plugin registrations.
## Workerd
Use the Workerd entrypoint inside a Cloudflare Durable Object. Hold one host for the lifetime of the object instance rather than creating one per request.
```ts
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
import myPlugin from "./my-plugin"
export class OpenCodeDO {
private readonly opencode: Promise<OpenCodeWorkerd.Interface>
constructor(state: DurableObjectState) {
this.opencode = state.blockConcurrencyWhile(() =>
OpenCodeWorkerd.create({
storage: state.storage,
config: { default_agent: "build" },
plugins: [myPlugin],
}),
)
}
async fetch() {
const opencode = await this.opencode
return Response.json(await opencode.health.get())
}
}
```
`blockConcurrencyWhile` keeps every Durable Object event out until the host is ready and resets the object if initialization fails. The retained Promise gives request handlers direct access to the same host after startup. Configuration is a typed JavaScript object, and plugins are imported values bundled with the Worker.
## Effect
The Effect-native API remains available from `@opencode-ai/sdk/effect`:
```ts
import { OpenCode } from "@opencode-ai/sdk/effect"
const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
```
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
```ts
const opencode =
yield *
OpenCode.create({
log: {
level: "warn",
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
},
})
```
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
```ts
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.get({ sessionID })
})
yield * program.pipe(Effect.provide(OpenCode.layer()))
```
`OpenCode.layer(options)` adapts the scoped `OpenCode.create(options)` convenience constructor for dependency injection.
Workspace providers are host infrastructure configured when the SDK is constructed. Workspace lifecycle operations remain on the typed facade:
```ts
const opencode = yield * OpenCode.create({ workspaceProviders: { modal: modalWorkspaceProvider } })
const workspace = yield * opencode.workspace.create({ provider: "modal" })
yield * opencode.workspace.destroy({ workspaceID: workspace.id })
```
The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`.

View file

@ -17,7 +17,9 @@
],
"exports": {
".": "./src/index.ts",
"./workerd": "./src/workerd.ts"
"./effect": "./src/effect/index.ts",
"./workerd": "./src/workerd.ts",
"./workerd/effect": "./src/effect/workerd.ts"
},
"scripts": {
"build": "bun run script/build.ts",
@ -28,6 +30,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",

View file

@ -7,7 +7,19 @@ import { join } from "node:path"
import { fileURLToPath } from "node:url"
const root = fileURLToPath(new URL("../../..", import.meta.url))
const names = ["schema", "codemode", "ai", "util", "protocol", "client", "plugin", "core", "simulation", "server", "sdk"]
const names = [
"schema",
"codemode",
"ai",
"util",
"protocol",
"client",
"plugin",
"core",
"simulation",
"server",
"sdk",
]
const temporary = await mkdtemp(join(tmpdir(), "opencode-sdk-package-"))
const archives = new Map<string, string>()
@ -29,7 +41,8 @@ try {
const unpacked = Object.keys(pkg.dependencies).filter(
(dependency) => dependency.startsWith("@opencode-ai/") && !archives.has(dependency),
)
if (unpacked.length > 0) throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`)
if (unpacked.length > 0)
throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`)
pkg.dependencies = Object.fromEntries(
Object.entries(pkg.dependencies).map(([dependency, version]) => {
const local = archives.get(dependency)
@ -50,7 +63,10 @@ try {
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(
Object.entries(conditions).map(([condition, value]) => [condition, output(name, value, condition === "types")]),
Object.entries(conditions).map(([condition, value]) => [
condition,
output(name, value, condition === "types"),
]),
),
]),
)
@ -86,28 +102,21 @@ try {
join(consumer, "worker.js"),
`import { bodyDigest } from "@opencode-ai/core/models-dev"
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
import { Effect } from "effect"
export class OpenCodeDO {
constructor(state) {
this.state = state
this.opencode = state.blockConcurrencyWhile(() => OpenCodeWorkerd.create({
storage: state.storage,
app: { version: "packed-workerd" },
}))
}
fetch() {
async fetch() {
if (bodyDigest("packed-workerd") !== "5fc174bf63e8dd108ebb6c53d85e7bbc4525b2f4c1c43280364cdbfd9b37aaf5") {
throw new Error("Packed workerd SHA-256 mismatch")
}
const storage = this.state.storage
return Effect.runPromise(
Effect.gen(function* () {
const sdk = yield* OpenCodeWorkerd.create({
storage,
app: { version: "packed-workerd" },
config: { content: "{}" },
})
return Response.json(yield* sdk.health.get())
}).pipe(Effect.scoped),
)
const opencode = await this.opencode
return Response.json(await opencode.health.get())
}
}
@ -142,6 +151,21 @@ try {
} finally {
await miniflare.dispose()
}
`,
),
Bun.write(
join(consumer, "imports.mjs"),
`const modules = await Promise.all([
import("@opencode-ai/sdk"),
import("@opencode-ai/sdk/effect"),
import("@opencode-ai/sdk/workerd"),
import("@opencode-ai/sdk/workerd/effect"),
])
for (const module of modules) {
const api = module.OpenCode ?? module.OpenCodeWorkerd
if (typeof api?.create !== "function") throw new Error("Packed SDK entrypoint is missing create()")
}
`,
),
])
@ -149,6 +173,8 @@ try {
const sdk = archives.get("@opencode-ai/sdk")
if (!sdk) throw new Error("Packed SDK archive was not created")
await $`npm install --ignore-scripts --no-audit --no-fund --package-lock=false ${sdk} wrangler@4.110.0`.cwd(consumer)
await $`bun imports.mjs`.cwd(consumer)
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
const transpiler = new Bun.Transpiler({ loader: "js" })
@ -159,12 +185,12 @@ try {
const bunGlobals = Array.from(new Set(bundled.match(/\bBun\.[A-Za-z_$][\w$]*/g) ?? []))
if (bunGlobals.length > 0) throw new Error(`Packed workerd bundle references Bun globals: ${bunGlobals.join(", ")}`)
const leaked = [
...transpiler.scanImports(bundled)
...transpiler
.scanImports(bundled)
.filter((imported) => imported.kind !== "dynamic-import")
.map((imported) => imported.path),
...Array.from(bundled.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g), (match) => match[1]),
]
.filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
].filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
if (leaked.length > 0) throw new Error(`Packed workerd bundle statically imports Bun builtins: ${leaked.join(", ")}`)
await $`node boot.mjs`.cwd(consumer)

View file

@ -0,0 +1,26 @@
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Workspace } from "@opencode-ai/schema/workspace"

View file

@ -0,0 +1,6 @@
export * as OpenCode from "./opencode"
export * as Tool from "./tool"
export { ClientError } from "@opencode-ai/client/effect"
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
export * from "../contracts"

View file

@ -0,0 +1,58 @@
export * as OpenCode from "./opencode"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
import type { Workspace } from "@opencode-ai/core/workspace"
import { Context, Effect, Layer } from "effect"
import type { Config, Scope } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { EmbeddedHost } from "../internal/host"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "../logging"
export type CreateOptions = EmbeddedHost.CreateOptions
export type EmbedOptions = EmbeddedHost.EmbedOptions
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly sessions: OpenCodeClient["session"]
readonly events: OpenCodeClient["event"]
readonly workspace: {
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
readonly provision: (options: {
readonly workspaceID: Workspace.ID
}) => ReturnType<Workspace.Interface["provision"]>
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
}
readonly plugin: EmbeddedHost.Interface["plugins"]["register"] & OpenCodeClient["plugin"]
}
export const create: (
options?: CreateOptions,
embed?: EmbedOptions,
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provide(
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
),
)
return {
...client,
sessions: client.session,
events: client.event,
workspace: {
create: ({ provider }: { readonly provider: string }) => host.workspace.create(provider),
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.provision(workspaceID),
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
},
plugin: Object.assign(host.plugins.register, client.plugin),
}
})
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
Layer.effect(Service, create(options))

View file

@ -0,0 +1,3 @@
export { RegistrationError } from "@opencode-ai/core/tool"
export { Error } from "@opencode-ai/schema/tool"
export type { Context, Info } from "@opencode-ai/schema/tool"

View file

@ -0,0 +1,24 @@
export * as OpenCodeWorkerd from "./workerd"
import { Layer } from "effect"
import type { Config, Scope } from "effect"
import { WorkerdProfile } from "../internal/workerd"
import { OpenCode } from "./opencode"
export type Configuration = WorkerdProfile.Configuration
export interface CreateOptions extends WorkerdProfile.Options {
readonly log?: OpenCode.CreateOptions["log"]
readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"]
}
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => {
const profile = WorkerdProfile.make(options)
return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements })
}
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
Layer.effect(OpenCode.Service, create(options))
export type Interface = OpenCode.Interface
export type Requirements = Scope.Scope

View file

@ -1,31 +1,6 @@
export * as OpenCode from "./opencode"
export * as Tool from "./tool"
export { ClientError } from "@opencode-ai/client/effect"
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Workspace } from "@opencode-ai/schema/workspace"
export { ClientError } from "@opencode-ai/client"
export type { OpenCodeEvent } from "@opencode-ai/client"
export * from "./contracts"

View file

@ -0,0 +1,114 @@
export * as OwnedFetch from "./fetch"
export function make(handler: (request: Request) => Promise<Response>, dispose: () => Promise<void>) {
const requests = new Set<Promise<void>>()
const shutdown = new AbortController()
const closed = new Error("OpenCode host is closed")
let closePromise: Promise<void> | undefined
const fetch = Object.assign(
(input: RequestInfo | URL, init?: RequestInit) => {
if (closePromise) return Promise.reject(closed)
const source = new Request(input, init)
if (source.signal.aborted) return Promise.reject(source.signal.reason)
const request = new Request(source, { signal: AbortSignal.any([source.signal, shutdown.signal]) })
const lifetime = Promise.withResolvers<void>()
const finish = () => {
requests.delete(lifetime.promise)
lifetime.resolve()
}
requests.add(lifetime.promise)
const handled = handler(request)
return rejectOnAbort(handled, request.signal).then(
(response) => trackResponse(response, request.signal, finish),
(cause) => {
void handled.then(finish, finish)
throw cause
},
)
},
{ preconnect: () => undefined },
) satisfies typeof globalThis.fetch
const close = () => {
if (closePromise) return closePromise
closePromise = Promise.resolve().then(async () => {
shutdown.abort(closed)
await Promise.allSettled(requests)
await dispose()
})
return closePromise
}
return { fetch, close }
}
function rejectOnAbort<A>(promise: Promise<A>, signal: AbortSignal): Promise<A> {
if (signal.aborted) return Promise.reject(signal.reason)
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason)
signal.addEventListener("abort", abort, { once: true })
promise.then(
(value) => {
signal.removeEventListener("abort", abort)
resolve(value)
},
(cause) => {
signal.removeEventListener("abort", abort)
reject(cause)
},
)
})
}
function trackResponse(response: Response, signal: AbortSignal, finish: () => void): Response {
if (!response.body) {
finish()
return response
}
const reader = response.body.getReader()
let done = false
let abort = () => {}
const complete = () => {
if (done) return false
done = true
signal.removeEventListener("abort", abort)
return true
}
const body = new ReadableStream<Uint8Array>({
start(controller) {
abort = () => {
if (!complete()) return
controller.error(signal.reason)
void reader.cancel(signal.reason).then(finish, finish)
}
if (signal.aborted) abort()
else signal.addEventListener("abort", abort, { once: true })
},
async pull(controller) {
try {
const next = await reader.read()
if (done) return
if (!next.done) {
controller.enqueue(next.value)
return
}
if (!complete()) return
controller.close()
finish()
} catch (cause) {
if (!complete()) return
controller.error(cause)
finish()
}
},
async cancel(reason) {
if (!complete()) return
try {
await reader.cancel(reason)
} finally {
finish()
}
},
})
return new Response(body, response)
}

View file

@ -0,0 +1,63 @@
export * as EmbeddedHost from "./host"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { context, layer, type LogOptions } from "../logging"
import { OwnedFetch } from "./fetch"
export interface CreateOptions extends Omit<ServerOptions, "hostname" | "port" | "password"> {
readonly log?: LogOptions
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
}
/** Host hooks for embedding opencode on a non-default runtime profile. */
export interface EmbedOptions {
readonly overrides?: LayerNode.Replacements
}
export const create = Effect.fn("EmbeddedHost.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const { log, workspaceProviders, ...server } = options
const runtime = ManagedRuntime.make(
createEmbeddedRoutes(
{
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
},
workspaceProviders
? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]]
: embed.overrides,
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))),
)
return yield* Effect.gen(function* () {
const services = yield* runtime.contextEffect
// The sweep is a no-op when nothing is suspended. ManagedRuntime owns the
// fiber so recovery never delays startup but still stops with the host.
runtime.runFork(Context.get(services, SessionRestart.Service).resumeSuspendedSessions)
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
context(services),
)(Context.get(services, HttpRouter.HttpRouter).asHttpEffect())
const transport = OwnedFetch.make(handler, runtime.dispose)
return {
runtime,
fetch: transport.fetch,
plugins: Context.get(services, SdkPlugins.Service),
workspace: Context.get(services, Workspace.Service),
close: transport.close,
}
}).pipe(Effect.onError(() => runtime.disposeEffect))
})
export type Interface = Effect.Success<ReturnType<typeof create>>

View file

@ -0,0 +1,21 @@
export * as WorkerdProfile from "./workerd"
import type { Config } from "@opencode-ai/schema/config"
import { ServerWorkerd } from "@opencode-ai/server/workerd"
export type Configuration = Omit<typeof Config.Info.Encoded, "plugins">
export interface Options extends Omit<ServerWorkerd.Options, "password" | "config"> {
readonly config?: Configuration
}
export function make({ config, ...options }: Options) {
const server = {
...options,
config: config === undefined ? undefined : { content: JSON.stringify(config) },
}
return {
options: ServerWorkerd.serverOptions(server),
replacements: ServerWorkerd.replacements(server),
}
}

View file

@ -1,127 +1,8 @@
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
import type { Database } from "@opencode-ai/core/database/database"
import type { ModelsDev } from "@opencode-ai/core/models-dev"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Config, Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import * as Logging from "./logging"
import { PromiseSdk } from "./promise"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
import type { LogOptions } from "./logging"
export interface CreateOptions {
readonly app?: {
readonly name?: string
readonly version?: string
readonly channel?: string
}
readonly hostname?: string
readonly port?: number
readonly password?: string
readonly simulation?: boolean
readonly database?: Database.Options
readonly events?: { readonly persist?: boolean }
readonly models?: ModelsDev.Options
readonly config?: {
readonly directory?: string
readonly project?: boolean
readonly file?: string
readonly content?: string
}
readonly windows?: { readonly gitbash?: string }
readonly fs?: {
readonly filewatcher?: boolean
readonly fff?: boolean
}
readonly log?: LogOptions
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
}
export type CreateOptions = PromiseSdk.CreateOptions
export type Interface = PromiseSdk.Interface
/** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */
export interface EmbedOptions {
readonly overrides?: LayerNode.Replacements
}
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly sessions: OpenCodeClient["session"]
readonly events: OpenCodeClient["event"]
readonly workspace: {
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
readonly provision: (options: {
readonly workspaceID: Workspace.ID
}) => ReturnType<Workspace.Interface["provision"]>
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
}
readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"]
}
export const create: (
options?: CreateOptions,
embed?: EmbedOptions,
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const { log, workspaceProviders, ...server } = options
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes(
{
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
},
workspaceProviders
? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]]
: embed.overrides,
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
),
),
(runtime) => runtime.disposeEffect,
)
const context = yield* runtime.contextEffect
// Unconditional, as on every runtime: the sweep is a no-op when nothing is
// suspended (always, for the default in-memory database). Forked so the
// returned client is never delayed; resumed drains are already logged and
// durably recorded by the execution layer.
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
const plugins = Context.get(context, SdkPlugins.Service)
const workspace = Context.get(context, Workspace.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
Logging.context(context),
)(router.asHttpEffect())
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)),
)
return {
...client,
sessions: client.session,
events: client.event,
workspace: {
create: ({ provider }: { readonly provider: string }) => workspace.create(provider),
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.provision(workspaceID),
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.destroy(workspaceID),
},
// The embedded host contributes plugins through the ordinary discovery flow:
// each plugin's `effect` runs inside every Location with the real
// `PluginContext`, so `ctx.agent.transform` and every other hook behave exactly
// as they do for a config-discovered plugin. Define agent profiles here at
// startup, then select one per Session with `sessions.create({ agent })`.
plugin: Object.assign(plugins.register, client.plugin),
}
})
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
Layer.effect(Service, create(options))
export const create = (options: CreateOptions = {}) => PromiseSdk.create(options)

View file

@ -0,0 +1,38 @@
export * as PromiseSdk from "./promise"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin"
import { Effect } from "effect"
import { EmbeddedHost } from "./internal/host"
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders"> {
readonly plugins?: ReadonlyArray<Plugin.Plugin>
}
export type Interface = Omit<OpenCodeClient, "plugin"> & {
readonly sessions: OpenCodeClient["session"]
readonly events: OpenCodeClient["event"]
readonly plugin: ((plugin: Plugin.Plugin) => Promise<void>) & OpenCodeClient["plugin"]
readonly close: () => Promise<void>
readonly [Symbol.asyncDispose]: () => Promise<void>
}
export async function create(options: CreateOptions = {}, embed: EmbeddedHost.EmbedOptions = {}): Promise<Interface> {
const { plugins, ...hostOptions } = options
const host = await Effect.runPromise(EmbeddedHost.create(hostOptions, embed))
const client = OpenCode.make({ baseUrl: "http://opencode.local", fetch: host.fetch })
const register = async (plugin: Plugin.Plugin) => {
const { PluginPromise } = await import("@opencode-ai/core/plugin/promise")
return host.runtime.runPromise(host.plugins.register(PluginPromise.fromPromise(plugin)))
}
for (const plugin of plugins ?? []) await register(plugin)
return {
...client,
sessions: client.session,
events: client.event,
plugin: Object.assign(register, client.plugin),
close: host.close,
[Symbol.asyncDispose]: host.close,
}
}

View file

@ -1,3 +1,3 @@
export { RegistrationError } from "@opencode-ai/core/tool"
export { Error } from "@opencode-ai/schema/tool"
export type { Context, Info } from "@opencode-ai/schema/tool"
export type { ToolContext as Context, Info } from "@opencode-ai/plugin/promise/tool"

View file

@ -1,16 +1,14 @@
export * as OpenCodeWorkerd from "./workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { ServerWorkerd } from "@opencode-ai/server/workerd"
import { Config, Effect, Layer, Scope } from "effect"
import * as OpenCode from "./opencode"
import { WorkerdProfile } from "./internal/workerd"
import type { LogOptions } from "./logging"
import { PromiseSdk } from "./promise"
export interface CreateOptions extends Pick<OpenCode.CreateOptions, "log" | "workspaceProviders"> {
readonly storage: DurableObjectStorage
readonly app?: OpenCode.CreateOptions["app"]
readonly password?: string
readonly config?: { readonly content?: string }
readonly models?: OpenCode.CreateOptions["models"]
export type Configuration = WorkerdProfile.Configuration
export interface CreateOptions extends WorkerdProfile.Options {
readonly log?: LogOptions
readonly plugins?: PromiseSdk.CreateOptions["plugins"]
}
/**
@ -27,17 +25,9 @@ export interface CreateOptions extends Pick<OpenCode.CreateOptions, "log" | "wor
* session operations plus the live `events.subscribe()` stream served over
* an in-process fetch transport, so no request leaves the isolate.
*/
export const create: (
options: CreateOptions,
) => Effect.Effect<OpenCode.Interface, Config.ConfigError | Error, Scope.Scope> = ({
log,
workspaceProviders,
...options
}) =>
OpenCode.create(
{ ...ServerWorkerd.serverOptions(options), log, workspaceProviders },
{ overrides: ServerWorkerd.replacements(options) },
)
export const create = ({ log, plugins, ...options }: CreateOptions) => {
const profile = WorkerdProfile.make(options)
return PromiseSdk.create({ ...profile.options, log, plugins }, { overrides: profile.replacements })
}
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
Layer.effect(OpenCode.Service, create(options))
export type Interface = PromiseSdk.Interface

View file

@ -11,10 +11,10 @@ import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Deferred, Effect, Fiber, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
import type { OpenCodeEvent } from "../src/effect"
const it = testEffect(Layer.empty)
type Sdk = typeof import("../src")
type Sdk = typeof import("../src/effect")
type Fixture = { readonly directory: string; readonly sdk: Sdk }
const withEmbedded = <A, E, R>(prefix: string, f: (fixture: Fixture) => Effect.Effect<A, E, R>) =>
@ -23,7 +23,9 @@ const withEmbedded = <A, E, R>(prefix: string, f: (fixture: Fixture) => Effect.E
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((directory) =>
Effect.promise(() => import("../src")).pipe(Effect.flatMap((sdk) => f({ directory: directory.path, sdk }))),
Effect.promise(() => import("../src/effect")).pipe(
Effect.flatMap((sdk) => f({ directory: directory.path, sdk })),
),
),
)

View file

@ -7,20 +7,22 @@ const client = resolve(import.meta.dir, "../../client")
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()
test("bundles the Promise and Effect clients with the in-memory host", async () => {
const bundles = await Promise.all([bundleInputs("@opencode-ai/sdk"), bundleInputs("@opencode-ai/sdk/effect")])
expect(within(inputs, client).length).toBeGreaterThan(0)
expect(within(inputs, core).length).toBeGreaterThan(0)
expect(within(inputs, server).length).toBeGreaterThan(0)
for (const inputs of bundles) {
expect(within(inputs, client).length).toBeGreaterThan(0)
expect(within(inputs, core).length).toBeGreaterThan(0)
expect(within(inputs, server).length).toBeGreaterThan(0)
}
})
async function bundleInputs() {
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"')
await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
const child = Bun.spawn(
[
process.execPath,

View file

@ -0,0 +1,124 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { OpenCode, Session } from "../src"
test("Promise host uses the embedded router and releases plugins", async () => {
await using directory = await tmpdir("opencode-promise-sdk-")
const config = join(directory.path, "config")
await mkdir(config)
const ready = Promise.withResolvers<void>()
let setup = false
let cleanup = false
const opencode = await OpenCode.create({
events: { persist: true },
config: { directory: config, project: false, content: "{}" },
plugins: [
{
id: `promise-${crypto.randomUUID()}`,
setup() {
setup = true
ready.resolve()
return () => {
cleanup = true
}
},
},
],
})
try {
const location = { directory: directory.path }
const session = await opencode.sessions.create({ location })
await opencode.plugin.list({ location })
await Promise.race([
ready.promise,
Bun.sleep(4_000).then(() => {
throw new Error("Promise plugin did not start")
}),
])
const selected = await opencode.sessions.get({ sessionID: session.id })
const page = await opencode.sessions.list({ directory: directory.path })
const events = Array.fromAsync(opencode.sessions.log({ sessionID: session.id }))
expect(selected.id).toBe(session.id)
expect(page.data.some((item) => item.id === session.id)).toBe(true)
expect((await events).some((event) => event.type === "session.created")).toBe(true)
expect(setup).toBe(true)
const missingSessionID = Session.ID.create()
const missing = await opencode.sessions.get({ sessionID: missingSessionID }).catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "SessionNotFoundError", sessionID: missingSessionID })
} finally {
await opencode.close()
await opencode.close()
}
expect(cleanup).toBe(true)
})
test("Promise event streams support cancellation", async () => {
await using directory = await tmpdir("opencode-promise-stream-")
const config = join(directory.path, "config")
await mkdir(config)
{
await using opencode = await OpenCode.create({ config: { directory: config, project: false, content: "{}" } })
const controller = new AbortController()
const events = opencode.events.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
expect(await events.next()).toMatchObject({ value: { type: "server.connected" }, done: false })
const pending = events.next()
controller.abort()
const error = await pending.catch((error: unknown) => error)
expect(error).toMatchObject({ name: "ClientError", reason: "Transport" })
await events.return?.()
}
})
test("closing cancels active Promise event streams", async () => {
await using directory = await tmpdir("opencode-promise-stream-close-")
const config = join(directory.path, "config")
await mkdir(config)
const opencode = await OpenCode.create({ config: { directory: config, project: false, content: "{}" } })
const events = opencode.events.subscribe()[Symbol.asyncIterator]()
expect(await events.next()).toMatchObject({ value: { type: "server.connected" }, done: false })
const pending = events.next()
await opencode.close()
const error = await pending.catch((error: unknown) => error)
expect(error).toMatchObject({ name: "ClientError", reason: "Transport" })
})
test("closing waits for pending Promise plugin setup and runs its cleanup", async () => {
await using directory = await tmpdir("opencode-promise-plugin-close-")
const config = join(directory.path, "config")
await mkdir(config)
await using opencode = await OpenCode.create({ config: { directory: config, project: false, content: "{}" } })
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<() => void>()
let cleanup = false
await opencode.plugin({
id: `pending-${crypto.randomUUID()}`,
setup() {
started.resolve()
return release.promise
},
})
const boot = opencode.plugin.list({ location: { directory: directory.path } })
await started.promise
const closing = opencode.close()
const concurrent = opencode.close()
try {
expect(concurrent).toBe(closing)
expect(await Promise.race([closing.then(() => "closed"), Promise.resolve("pending")])).toBe("pending")
} finally {
release.resolve(() => {
cleanup = true
})
await closing
await boot.catch(() => undefined)
}
expect(cleanup).toBe(true)
})

View file

@ -2,16 +2,7 @@
title: "SDK"
---
We're working on a general-purpose SDK for embedding OpenCode directly inside
your application. The regular SDK is coming soon.
An Effect-native version is available now for applications built with Effect.
Its current documentation is below. For other applications, run OpenCode as a
server and use the [TypeScript client](/build/client) in the meantime.
## Effect
`@opencode-ai/sdk` hosts OpenCode in-process. Unlike the
`@opencode-ai/sdk` hosts OpenCode directly inside your application. Unlike the
[network client](/build/client), it assembles the OpenCode server and routes API
calls through its HTTP router in memory. It opens no HTTP listener and adds no
network hop between the client and server.
@ -23,54 +14,135 @@ network hop between the client and server.
## Create a host
`OpenCode.create()` creates a scoped host. Closing its Effect Scope releases
the router, location services, fibers, and scoped plugin registrations.
`OpenCode.create()` returns an explicitly owned host. Use `await using` to
release its router, Location services, fibers, and scoped plugin registrations:
```ts
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/sdk"
import { OpenCode } from "@opencode-ai/sdk"
await using opencode = await OpenCode.create()
const session = await opencode.sessions.create({
location: { directory: "/workspace" },
})
await opencode.sessions.prompt({
sessionID: session.id,
text: "Review the current changes",
})
```
Call `await opencode.close()` explicitly when explicit resource management is
not available.
The embedded host uses the same Promise values, declared errors, request
options, and `AsyncIterable` streams as `@opencode-ai/client`. It exposes the
full generated client and adds the convenience aliases `sessions` and `events`
for the session and event groups.
## Stream events
```ts
for await (const event of opencode.events.subscribe()) {
console.log(event.type)
}
```
Pass an `AbortSignal` through the generated request options, or leave an
iteration to cancel its response body.
## Register plugins
Pass initial Promise plugins to `OpenCode.create()`. Embedded plugins use the
same discovery and Location-scoped activation path as configured plugins.
```ts
const plugin = {
id: "example",
async setup(ctx) {
await ctx.agent.transform((agents) => {
// Modify the Location's agent catalog.
})
},
}
await using opencode = await OpenCode.create({ plugins: [plugin] })
```
Call `await opencode.plugin(plugin)` to register another plugin after startup.
See the [Plugins guide](/build/plugins) for the plugin context and available
hooks.
## Workerd
Use `@opencode-ai/sdk/workerd` inside a Cloudflare Durable Object. This profile
uses the object's SQLite storage, persists durable events for eviction recovery,
and replaces unavailable local filesystem and process services.
Hold one host for the lifetime of the Durable Object instance instead of
creating one for every request:
```ts
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
import myPlugin from "./my-plugin"
export class OpenCodeDO {
private readonly opencode: Promise<OpenCodeWorkerd.Interface>
constructor(state: DurableObjectState) {
this.opencode = state.blockConcurrencyWhile(() =>
OpenCodeWorkerd.create({
storage: state.storage,
config: { default_agent: "build" },
plugins: [myPlugin],
}),
)
}
async fetch() {
const opencode = await this.opencode
return Response.json(await opencode.health.get())
}
}
```
`blockConcurrencyWhile` keeps every Durable Object event out until the host is
ready and resets the object if initialization fails. The retained Promise gives
request handlers direct access to the same host after startup. Configuration is
a typed JavaScript object, and plugins are imported values bundled with the
Worker.
Wrangler selects OpenCode's Workerd-safe low-level implementations through the
`workerd` package condition. Cloudflare may evict a Durable Object without
running cleanup, so correctness does not depend on `close()` being called.
## Effect
Import the Effect-native API from `@opencode-ai/sdk/effect`. Closing its Effect
Scope releases the embedded host:
```ts
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/sdk/effect"
import { Effect } from "effect"
const program = Effect.scoped(
Effect.gen(function* () {
const opencode = yield* OpenCode.create()
const session = yield* opencode.sessions.create({
return yield* opencode.sessions.create({
location: Location.Ref.make({
directory: AbsolutePath.make("/workspace"),
}),
})
return yield* opencode.sessions.get({ sessionID: session.id })
}),
)
const session = await Effect.runPromise(program)
```
The embedded host uses the same routes, middleware, codecs, errors, and schema
values as `@opencode-ai/client/effect`. It exposes the full generated client and
adds the convenience aliases `sessions` and `events` for the session and event
groups.
Effect applications can contribute plugins from ordinary registration layers.
The registration layer may depend on `OpenCode.Service` and any other services
needed to construct the plugin; `OpenCode.layer()` remains unaware of those
features.
## Use as a service
Use `OpenCode.layer` when the host should be provided through Effect dependency
injection:
```ts
import { OpenCode } from "@opencode-ai/sdk"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.active()
})
const active = await Effect.runPromise(program.pipe(Effect.provide(OpenCode.layer)))
```
## Register plugins
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
use the same discovery and location-scoped activation path as configured
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
Use `OpenCode.layer()` for dependency injection. The Effect-native Workerd
entrypoint is `@opencode-ai/sdk/workerd/effect`.