Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/src/location-service-map.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import path from "path"
import { Location } from "./location.js"
import type { Instance } from "./instance.js"

Expand All @@ -15,4 +17,12 @@ export class Service extends Context.Service<

export const node = LayerNode.unbound(Service, Node.tags.values.global)

/** Normalize equivalent placements before they become resource-cache keys. */
export function canonical(ref: Location.Ref) {
return Location.Ref.make({
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
}

export * as LocationServiceMap from "./location-service-map.js"
17 changes: 4 additions & 13 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { AbsolutePath } from "./schema.js"

export { LocationServiceMap } from "./location-service-map.js"

Expand All @@ -15,13 +13,6 @@ export type LocationError = Instance.Error
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
// Structural Equal distinguishes optional-key shape and Windows separator style.
// The RcMap caches the raw key before the build callback, so normalize both here.
const canonical = (ref: Location.Ref) =>
Location.Ref.make({
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
Expand All @@ -35,10 +26,10 @@ export function buildLocationServiceMap(
})
const map = {
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
Expand Down
69 changes: 69 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,53 @@ await using opencode = await OpenCode.create({

`close()` and `Symbol.asyncDispose` release router resources, Location services, fibers, and scoped plugin registrations.

## Session-Selected Plugins

Use `instances` when Sessions in the same directory need different application plugins. The application selects a stable key from Session metadata; the SDK constructs and caches an instance for that key and the Session's current Location.

```ts
import { OpenCode } from "@opencode-ai/sdk"
import { threads } from "./threads"
import { slackPlugin } from "./slack-plugin"

await using opencode = await OpenCode.create({
database: { path: "./sessions.db" },
instances: {
key(session) {
const threadID = session.metadata?.threadID
if (typeof threadID !== "string") throw new Error("Session has no thread ID")
return threadID
},
configure: async (threadID) => ({
plugins: [slackPlugin(await threads.get(threadID))],
}),
},
})

const session = await opencode.sessions.create({
location: { directory: "/workspace" },
metadata: { threadID: "thread-42" },
})
await opencode.sessions.prompt({ sessionID: session.id, text: "Review the changes" })
```

`threads` and `slackPlugin` are application-owned modules. `key` is synchronous and should only select identity, not initialize plugins. `configure` returns plugin definitions; their setup receives the selected instance's `ctx.location`.

- The same key and Location share one live instance. Different directories or workspace IDs always select separate instances, even with the same application key.
- `configure` runs on a cache miss, not on each prompt. Loaded instances live until the host closes; change the application key or restart the host to reconstruct their birth configuration. Plugin transforms and reloads remain available within that lifetime.
- Session metadata, message, inbox, and context reads do not initialize plugins; permission and form lists read instance services and therefore acquire the Session's instance. Configuration failure, an instance plugin ID that collides with a host `plugins` entry, or initial setup failure of a supplied plugin prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
- Existing HTTP prompt middleware also acquires capabilities for an idempotent retry. After restart, that retry can reconstruct plugins before returning the original admission; prompt preparation and hooks do not rerun. Configuration failure can therefore block the retry even when its input was already saved.
- Instance selection is not an authorization or storage-isolation boundary. Plugin Session APIs and the existing plugin-ID-based durable storage retain their normal scope.
- Omitting `instances` preserves default Location sharing. Host-wide plugins remain separate from Session-selected configuration; retain host-wide catalog policy when locationless generation needs it.

### Restart and Lifetime

The selector is installed before automatic recovery starts. Its callbacks must be able to load application data without depending on the returned `opencode` handle or a later registration call. Functions are reconstructed, not serialized.

Use a persistent `database.path` to recover Sessions after restart; the default database is in memory. Workerd uses its injected Durable Object storage. After restart, the next capability-dependent operation or recovery drain rebuilds the selected instance from saved Session metadata and application data.

Promise plugin resources should be acquired in `setup` and released by its cleanup function. Effect configuration can acquire resources in its supplied instance Scope; capture application services before creating the SDK host.

## 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.
Expand Down Expand Up @@ -70,3 +117,25 @@ const session = yield * opencode.sessions.get({ sessionID })
```

The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`.

Effect configuration uses the same keys and lifetime rules, with canonical `Session.Info` values and an Effect-returning factory:

```ts
import { OpenCode } from "@opencode-ai/sdk/effect"
import { Effect, Schema } from "effect"
import { threads } from "./threads-effect"
import { slackPlugin } from "./slack-plugin-effect"

const threadMetadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String }))
const opencode =
yield *
OpenCode.create({
database: { path: "./sessions.db" },
instances: {
key: (session) => threadMetadata(session.metadata).threadID,
configure: (threadID) => threads.get(threadID).pipe(Effect.map((thread) => ({ plugins: [slackPlugin(thread)] }))),
},
})
```

Both Workerd entrypoints also accept `instances`. The public `OpenCode.InstanceOptions` and `OpenCode.InstanceConfiguration` types describe the corresponding Promise or Effect callbacks.
33 changes: 33 additions & 0 deletions packages/sdk/script/verify-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,30 @@ import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"

export class OpenCodeDO {
constructor(state) {
this.configurations = 0
this.opencode = state.blockConcurrencyWhile(() => OpenCodeWorkerd.create({
storage: state.storage,
app: { version: "packed-workerd" },
models: { fetch: false },
instances: {
key: session => String(session.metadata.thread),
configure: key => {
this.configurations++
return {
plugins: [{
id: "packed-instance",
async setup(ctx) {
if (ctx.app.version !== "packed-workerd" || ctx.location.directory !== "/workspace") {
throw new Error("Selected instance did not inherit the host configuration")
}
await ctx.session.hook("prompt", event => {
event.prompt.text += ":" + key
})
},
}],
}
},
},
}))
}

Expand All @@ -116,6 +137,18 @@ export class OpenCodeDO {
throw new Error("Packed workerd SHA-256 mismatch")
}
const opencode = await this.opencode
const sessions = await Promise.all([1, 2].map(() => opencode.sessions.create({
location: { directory: "/workspace" },
metadata: { thread: "packed-thread" },
})))
const admitted = await Promise.all(sessions.map(session => opencode.sessions.prompt({
sessionID: session.id,
text: "Packed prompt",
resume: false,
})))
if (this.configurations !== 1 || admitted.some(item => item.payload.text !== "Packed prompt:packed-thread")) {
throw new Error("Packed instance configuration did not share or prepare prompts correctly")
}
return Response.json(await opencode.health.get())
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/effect/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { Context, Effect, Layer } from "effect"
import type { Config, Scope } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { EmbeddedHost } from "../internal/host"
import type { SdkInstances } from "../internal/instances"

export type { LogEntry, LogLevel, LogOptions, LogWriter } from "../logging"

export type CreateOptions = EmbeddedHost.CreateOptions
export type EmbedOptions = EmbeddedHost.EmbedOptions
export type InstanceOptions = SdkInstances.Options
export type InstanceConfiguration = SdkInstances.Configuration

export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly sessions: OpenCodeClient["session"]
Expand Down
8 changes: 6 additions & 2 deletions packages/sdk/src/effect/workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ export type Configuration = WorkerdProfile.Configuration
export interface CreateOptions extends WorkerdProfile.Options {
readonly log?: OpenCode.CreateOptions["log"]
readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"]
readonly instances?: OpenCode.CreateOptions["instances"]
}

export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => {
export const create = ({ log, workspaceProviders, instances, ...options }: CreateOptions) => {
const profile = WorkerdProfile.make(options)
return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements })
return OpenCode.create(
{ ...profile.options, log, workspaceProviders, instances },
{ overrides: profile.replacements },
)
}

export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/src/internal/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ 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"
import { SdkInstances } from "./instances"

export interface CreateOptions extends Omit<ServerOptions, "hostname" | "port" | "password"> {
readonly log?: LogOptions
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
readonly instances?: SdkInstances.Options
}

/** Host hooks for embedding opencode on a non-default runtime profile. */
Expand All @@ -26,7 +28,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const { log, workspaceProviders, ...server } = options
const { log, workspaceProviders, instances, ...server } = options
const runtime = ManagedRuntime.make(
createEmbeddedRoutes(
{
Expand All @@ -37,6 +39,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
workspaceProviders
? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))]
: embed.overrides,
instances ? (replacements) => SdkInstances.node(instances, replacements) : undefined,
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))),
)

Expand Down
100 changes: 100 additions & 0 deletions packages/sdk/src/internal/instances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
export * as SdkInstances from "./instances"

import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Plugin } from "@opencode-ai/core/plugin"
import type { InstancePlugins } from "@opencode-ai/core/plugin/instance"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Location } from "@opencode-ai/schema/location"
import type { Session } from "@opencode-ai/schema/session"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Duration, Effect, Layer, LayerMap, Scope } from "effect"

export interface Configuration {
readonly plugins: InstancePlugins.List
}

export interface Options {
/** Select a sharing key within the Session's current Location. Must not initialize plugins. */
readonly key: (session: Session.Info) => string
/** Reconstruct configuration on a cache miss. Resources belong to the instance Scope. */
readonly configure: (key: string) => Effect.Effect<Configuration, unknown, Scope.Scope>
}

/** Replaces the host's `Instance.node`; `replacements` resolves lazily so instances inherit the final host graph. */
export function node(options: Options, replacements: () => LayerNode.Replacements) {
return makeGlobalNode({
service: Instance.Service,
layer: layer(options, replacements),
deps: [LocationServiceMap.node, SdkPlugins.node],
})
}

export function layer(options: Options, replacements: () => LayerNode.Replacements) {
return Layer.effect(
Instance.Service,
Effect.gen(function* () {
const scope = yield* Effect.scope
const locations = yield* LocationServiceMap.Service
const sdk = yield* SdkPlugins.Service
const key = (session: Session.Info) => ({
key: options.key(session),
...LocationServiceMap.canonical(session.location),
})
const provide = (session: Session.Info) => Effect.provide(instances.get(key(session)))
const instances: LayerMap.LayerMap<ReturnType<typeof key>, Instance.Services> = yield* LayerMap.make(
(input: ReturnType<typeof key>) =>
Layer.unwrap(
Effect.gen(function* () {
const configuration = yield* options.configure(input.key).pipe(Effect.orDie)
// A host/instance ID collision fails the whole plugin generation, which leaves no inventory
// trace to check after activation. Reject it before constructing anything.
const collisions = configuration.plugins.filter((plugin) =>
sdk.all().some((host) => host.id === plugin.id),
)
if (collisions.length > 0)
yield* Effect.die(
new Error(
`Instance plugin IDs collide with host plugins: ${collisions.map((plugin) => plugin.id).join(", ")}`,
),
)
return Instance.layer(Location.Ref.make({ directory: input.directory, workspaceID: input.workspaceID }), {
plugins: configuration.plugins,
replacements: [
...replacements(),
// Instances borrow this selector and the host's Location map instead of retaining
// their Layer scopes; retaining the selector would block its shutdown on its own entries.
Instance.node.replace(Layer.succeed(Instance.Service, { provide })),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
],
}).pipe(
Layer.tap((context) =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const failed = (yield* plugins.list()).filter(
(plugin) =>
plugin.state.status === "failed" &&
configuration.plugins.some((configured) => configured.id === plugin.id),
)
if (failed.length > 0)
yield* Effect.die(
new Error(`Instance plugin setup failed: ${failed.map((plugin) => plugin.id).join(", ")}`),
)
}).pipe(Effect.provide(context)),
),
)
}),
).pipe(
// Eviction can close the lookup's scope; do not make that fiber wait on itself.
Layer.tapCause(() =>
instances.invalidate(input).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid),
),
),
{ idleTimeToLive: Duration.infinity },
)
return Instance.Service.of({ provide })
}),
)
}
2 changes: 2 additions & 0 deletions packages/sdk/src/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { PromiseSdk } from "./promise"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"

export type CreateOptions = PromiseSdk.CreateOptions
export type InstanceOptions = PromiseSdk.InstanceOptions
export type InstanceConfiguration = PromiseSdk.InstanceConfiguration
export type Interface = PromiseSdk.Interface

export const create = (options: CreateOptions = {}) => PromiseSdk.create(options)
Loading
Loading