| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- import { Tool } from "@opencode-ai/schema/tool"
- import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
- import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
- import { define } from "../effect/plugin.js"
- import type { PluginEventType } from "./event.js"
- import type { Context, Plugin } from "./plugin.js"
- import type { Info } from "./tool.js"
- type HostRegistration = { readonly dispose: Effect.Effect<void> }
- type Registration = { readonly dispose: () => Promise<void> }
- type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
- interface CompiledEndpoint {
- readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
- readonly encode: (output: unknown) => Effect.Effect<unknown, Schema.SchemaError>
- readonly noContent: boolean
- }
- const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
- function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
- const cached = compiledEndpoints.get(endpoint)
- if (cached) return cached
- const payloadSchemas = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
- const successSchemas = Array.from(endpoint.success)
- if (payloadSchemas.length > 1 || successSchemas.length > 1) {
- throw new Error(`Unsupported API schema cardinality: ${endpoint.identifier}`)
- }
- const inputs = [
- endpoint.params,
- endpoint.query === undefined ? undefined : Schema.toType(endpoint.query),
- endpoint.headers,
- ...payloadSchemas,
- ].filter((schema): schema is Schema.Top => schema !== undefined) as Array<RuntimeSchema>
- const success = (successSchemas[0] ?? HttpApiSchema.NoContent) as RuntimeSchema
- const noContent = HttpApiSchema.isNoContent(success.ast)
- const type = Schema.toType(success).ast
- const data = SchemaAST.isObjects(success.ast)
- ? success.ast.propertySignatures.find((property) => property.name === "data")
- : undefined
- const output =
- !noContent &&
- SchemaAST.isObjects(type) &&
- type.indexSignatures.length === 0 &&
- type.propertySignatures.length === 1 &&
- type.propertySignatures[0]?.name === "data" &&
- data !== undefined
- ? (Schema.make<Schema.Top>(data.type) as RuntimeSchema)
- : success
- const compiled = {
- decode: inputs.map((schema) => Schema.decodeUnknownEffect(schema)),
- encode: Schema.encodeUnknownEffect(output),
- noContent,
- } satisfies CompiledEndpoint
- compiledEndpoints.set(endpoint, compiled)
- return compiled
- }
- /**
- * Adapts a Promise plugin into an Effect plugin so the existing Effect-only
- * loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
- *
- * Hook registrations created during the async `setup` attach to the plugin's
- * scope, so unloading the plugin disposes them. The captured fiber context
- * preserves boot-time batching, so Promise-plugin transforms still coalesce
- * into one reload per domain.
- */
- export function fromPromise(plugin: Plugin) {
- return define({
- id: plugin.id,
- effect: (host) =>
- Effect.gen(function* () {
- const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
- Promise.all([import("@opencode-ai/protocol/client"), import("@opencode-ai/protocol/groups/event")]),
- )
- const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
- const CommandEndpoints = ClientApi.groups["server.command"].endpoints
- const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
- const ModelEndpoints = ClientApi.groups["server.model"].endpoints
- const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
- const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
- const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
- 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) => ({
- dispose: () => Effect.runPromiseWith(context)(registration.dispose),
- }))
- const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
- const adaptApiMethod = <PromiseMethod>(
- endpoint: HttpApiEndpoint.Top,
- method: (input: never) => Effect.Effect<unknown, unknown>,
- ) => {
- const compiled = compileEndpoint(endpoint)
- return ((input?: unknown) =>
- Effect.gen(function* () {
- const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
- const result = yield* method(Object.assign({}, ...decoded) as never)
- if (compiled.noContent) return undefined
- return yield* compiled.encode(result)
- }).pipe(Effect.runPromiseWith(context))) as PromiseMethod
- }
- const transform =
- <Draft>(domain: {
- transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
- }) =>
- (callback: (draft: Draft) => void) =>
- register(
- domain.transform((draft) => {
- callback(draft)
- }),
- )
- const context2: Context = {
- app: host.app,
- options: host.options,
- agent: {
- get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
- list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
- transform: transform(host.agent),
- reload: () => run(host.agent.reload()),
- },
- aisdk: {
- hook: (name, callback) =>
- register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
- },
- catalog: {
- provider: {
- list: adaptApiMethod(ProviderEndpoints["provider.list"], host.catalog.provider.list),
- get: adaptApiMethod(ProviderEndpoints["provider.get"], host.catalog.provider.get),
- },
- model: {
- list: adaptApiMethod(ModelEndpoints["model.list"], host.catalog.model.list),
- default: adaptApiMethod(ModelEndpoints["model.default"], host.catalog.model.default),
- },
- transform: transform(host.catalog),
- reload: () => run(host.catalog.reload()),
- },
- command: {
- list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
- transform: transform(host.command),
- reload: () => run(host.command.reload()),
- },
- event: {
- subscribe: (type?: PluginEventType) => {
- const events = type === undefined ? host.event.subscribe() : host.event.subscribe(type)
- return Stream.toAsyncIterable(
- events.pipe(
- Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
- Stream.map((event) => event as unknown as PromiseEvent),
- ),
- )
- },
- },
- integration: {
- list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
- get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
- connect: {
- key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
- },
- oauth: {
- connect: adaptApiMethod(
- IntegrationEndpoints["integration.oauth.connect"],
- host.integration.oauth.connect,
- ),
- status: adaptApiMethod(IntegrationEndpoints["integration.oauth.status"], host.integration.oauth.status),
- complete: adaptApiMethod(
- IntegrationEndpoints["integration.oauth.complete"],
- host.integration.oauth.complete,
- ),
- cancel: adaptApiMethod(IntegrationEndpoints["integration.oauth.cancel"], host.integration.oauth.cancel),
- },
- command: {
- connect: adaptApiMethod(
- IntegrationEndpoints["integration.command.connect"],
- host.integration.command.connect,
- ),
- status: adaptApiMethod(
- IntegrationEndpoints["integration.command.status"],
- host.integration.command.status,
- ),
- cancel: adaptApiMethod(
- IntegrationEndpoints["integration.command.cancel"],
- host.integration.command.cancel,
- ),
- },
- transform: (callback) =>
- register(
- host.integration.transform((draft) =>
- callback({
- list: draft.list,
- get: draft.get,
- update: draft.update,
- remove: draft.remove,
- method: {
- list: draft.method.list,
- update: (input) => {
- if (!("authorize" in input)) return draft.method.update(input)
- const refresh = input.refresh
- draft.method.update({
- ...input,
- authorize: (answer) =>
- Effect.promise(() => input.authorize(answer)).pipe(
- Effect.map((authorization) =>
- authorization.mode === "auto"
- ? {
- ...authorization,
- callback: Effect.promise(() => authorization.callback),
- }
- : {
- ...authorization,
- callback: (code) => Effect.promise(() => authorization.callback(code)),
- },
- ),
- ),
- refresh:
- refresh === undefined
- ? undefined
- : (credential) => Effect.promise(() => refresh(credential)),
- })
- },
- remove: draft.method.remove,
- },
- }),
- ),
- ),
- reload: () => run(host.integration.reload()),
- connection: {
- active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
- resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
- },
- },
- plugin: {
- list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
- },
- reference: {
- list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
- transform: transform(host.reference),
- reload: () => run(host.reference.reload()),
- },
- skill: {
- list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
- transform: transform(host.skill),
- reload: () => run(host.skill.reload()),
- },
- tool: {
- transform: (callback) =>
- register(
- host.tool.transform((draft) =>
- callback({
- add: (tool: Info) =>
- draft.add({
- ...tool,
- execute: (input, context) => executePromiseTool(tool, input, context),
- }),
- }),
- ),
- ),
- hook: (name, callback) =>
- register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
- },
- websearch: {
- providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
- query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query),
- reload: () => run(host.websearch.reload()),
- transform: (callback) =>
- register(
- host.websearch.transform((draft) => {
- callback({
- add: (definition) =>
- draft.add({
- id: definition.id,
- name: definition.name,
- execute: (input) => attempt((signal) => definition.execute(input, { signal })),
- }),
- default: draft.default,
- })
- }),
- ),
- },
- session: {
- hook: (name, callback) =>
- register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
- create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
- get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
- prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
- generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
- command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
- synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
- interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
- rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
- wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
- },
- shell: {
- hook: (name, callback) =>
- register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
- },
- }
- const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
- if (!cleanup) return
- yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
- }),
- })
- }
- function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
- return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
- }
- type RuntimeSchema = Schema.Codec<unknown, unknown>
- const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
- Effect.promise(() =>
- tool.execute(input, {
- ...context,
- progress: (update) => Effect.runPromise(context.progress(update)),
- }),
- )
|