diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index a0459205037..6446fb7e678 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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() // Run a hook registration on the plugin scope and resolve once it is registered. const register = (effect: Effect.Effect): Promise => - 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), + ) }), }) } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 14e7086317e..ceb2309f274 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -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 + + 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`. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1b33d5ca72d..a595fef58dd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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:*", diff --git a/packages/sdk/script/verify-package.ts b/packages/sdk/script/verify-package.ts index 3545e78d990..054f94a7f72 100644 --- a/packages/sdk/script/verify-package.ts +++ b/packages/sdk/script/verify-package.ts @@ -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() @@ -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) diff --git a/packages/sdk/src/contracts.ts b/packages/sdk/src/contracts.ts new file mode 100644 index 00000000000..7778b8fbb32 --- /dev/null +++ b/packages/sdk/src/contracts.ts @@ -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" diff --git a/packages/sdk/src/effect/index.ts b/packages/sdk/src/effect/index.ts new file mode 100644 index 00000000000..9597380c85e --- /dev/null +++ b/packages/sdk/src/effect/index.ts @@ -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" diff --git a/packages/sdk/src/effect/opencode.ts b/packages/sdk/src/effect/opencode.ts new file mode 100644 index 00000000000..47ddfdd704b --- /dev/null +++ b/packages/sdk/src/effect/opencode.ts @@ -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 & { + readonly sessions: OpenCodeClient["session"] + readonly events: OpenCodeClient["event"] + readonly workspace: { + readonly create: (options: { readonly provider: string }) => ReturnType + readonly provision: (options: { + readonly workspaceID: Workspace.ID + }) => ReturnType + readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType + } + readonly plugin: EmbeddedHost.Interface["plugins"]["register"] & OpenCodeClient["plugin"] +} + +export const create: ( + options?: CreateOptions, + embed?: EmbedOptions, +) => Effect.Effect = 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()("@opencode-ai/sdk/OpenCode") {} + +export const layer = (options: CreateOptions = {}): Layer.Layer => + Layer.effect(Service, create(options)) diff --git a/packages/sdk/src/effect/tool.ts b/packages/sdk/src/effect/tool.ts new file mode 100644 index 00000000000..af878df5fd5 --- /dev/null +++ b/packages/sdk/src/effect/tool.ts @@ -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" diff --git a/packages/sdk/src/effect/workerd.ts b/packages/sdk/src/effect/workerd.ts new file mode 100644 index 00000000000..dc985be10d2 --- /dev/null +++ b/packages/sdk/src/effect/workerd.ts @@ -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 => + Layer.effect(OpenCode.Service, create(options)) + +export type Interface = OpenCode.Interface +export type Requirements = Scope.Scope diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index bb9246d542d..220720d96c1 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -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" diff --git a/packages/sdk/src/internal/fetch.ts b/packages/sdk/src/internal/fetch.ts new file mode 100644 index 00000000000..5fab62ff822 --- /dev/null +++ b/packages/sdk/src/internal/fetch.ts @@ -0,0 +1,114 @@ +export * as OwnedFetch from "./fetch" + +export function make(handler: (request: Request) => Promise, dispose: () => Promise) { + const requests = new Set>() + const shutdown = new AbortController() + const closed = new Error("OpenCode host is closed") + let closePromise: Promise | 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() + 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(promise: Promise, signal: AbortSignal): Promise { + 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({ + 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) +} diff --git a/packages/sdk/src/internal/host.ts b/packages/sdk/src/internal/host.ts new file mode 100644 index 00000000000..7adb2111f73 --- /dev/null +++ b/packages/sdk/src/internal/host.ts @@ -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 { + readonly log?: LogOptions + readonly workspaceProviders?: Readonly> +} + +/** 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( + 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> diff --git a/packages/sdk/src/internal/workerd.ts b/packages/sdk/src/internal/workerd.ts new file mode 100644 index 00000000000..e9724f37c79 --- /dev/null +++ b/packages/sdk/src/internal/workerd.ts @@ -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 + +export interface Options extends Omit { + 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), + } +} diff --git a/packages/sdk/src/opencode.ts b/packages/sdk/src/opencode.ts index e1a4fa6e5ae..86c5f60da37 100644 --- a/packages/sdk/src/opencode.ts +++ b/packages/sdk/src/opencode.ts @@ -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> -} +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 & { - readonly sessions: OpenCodeClient["session"] - readonly events: OpenCodeClient["event"] - readonly workspace: { - readonly create: (options: { readonly provider: string }) => ReturnType - readonly provision: (options: { - readonly workspaceID: Workspace.ID - }) => ReturnType - readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType - } - readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"] -} - -export const create: ( - options?: CreateOptions, - embed?: EmbedOptions, -) => Effect.Effect = 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( - 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()("@opencode-ai/sdk/OpenCode") {} - -export const layer = (options: CreateOptions = {}): Layer.Layer => - Layer.effect(Service, create(options)) +export const create = (options: CreateOptions = {}) => PromiseSdk.create(options) diff --git a/packages/sdk/src/promise.ts b/packages/sdk/src/promise.ts new file mode 100644 index 00000000000..a113df0ee75 --- /dev/null +++ b/packages/sdk/src/promise.ts @@ -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 { + readonly plugins?: ReadonlyArray +} + +export type Interface = Omit & { + readonly sessions: OpenCodeClient["session"] + readonly events: OpenCodeClient["event"] + readonly plugin: ((plugin: Plugin.Plugin) => Promise) & OpenCodeClient["plugin"] + readonly close: () => Promise + readonly [Symbol.asyncDispose]: () => Promise +} + +export async function create(options: CreateOptions = {}, embed: EmbeddedHost.EmbedOptions = {}): Promise { + 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, + } +} diff --git a/packages/sdk/src/tool.ts b/packages/sdk/src/tool.ts index af878df5fd5..7ab994a3c1b 100644 --- a/packages/sdk/src/tool.ts +++ b/packages/sdk/src/tool.ts @@ -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" diff --git a/packages/sdk/src/workerd.ts b/packages/sdk/src/workerd.ts index 1d3173011dd..c9c21c9ecbe 100644 --- a/packages/sdk/src/workerd.ts +++ b/packages/sdk/src/workerd.ts @@ -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 { - 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 Effect.Effect = ({ - 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 => - Layer.effect(OpenCode.Service, create(options)) +export type Interface = PromiseSdk.Interface diff --git a/packages/sdk/test/embedded.test.ts b/packages/sdk/test/embedded.test.ts index ae99a30a640..89f2258bfbe 100644 --- a/packages/sdk/test/embedded.test.ts +++ b/packages/sdk/test/embedded.test.ts @@ -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 = (prefix: string, f: (fixture: Fixture) => Effect.Effect) => @@ -23,7 +23,9 @@ const withEmbedded = (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 })), + ), ), ) diff --git a/packages/sdk/test/import-boundaries.test.ts b/packages/sdk/test/import-boundaries.test.ts index 557854a5515..03a64b496a2 100644 --- a/packages/sdk/test/import-boundaries.test.ts +++ b/packages/sdk/test/import-boundaries.test.ts @@ -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, diff --git a/packages/sdk/test/promise.test.ts b/packages/sdk/test/promise.test.ts new file mode 100644 index 00000000000..4aeb86f2936 --- /dev/null +++ b/packages/sdk/test/promise.test.ts @@ -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() + 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() + 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) +}) diff --git a/packages/www/src/docs/content/build/sdk.mdx b/packages/www/src/docs/content/build/sdk.mdx index 295d77f0b95..8d4f605057b 100644 --- a/packages/www/src/docs/content/build/sdk.mdx +++ b/packages/www/src/docs/content/build/sdk.mdx @@ -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 + + 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`.