Merge remote-tracking branch 'origin/v2' into nxl/vcs-plugin-api

# Conflicts:
#	packages/core/src/config.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/project.ts
#	packages/core/test/project.test.ts
#	packages/plugin/src/v2/effect/context.ts
#	packages/plugin/src/v2/effect/index.ts
This commit is contained in:
Shoubhit Dash 2026-07-06 18:06:51 +05:30
commit 8ede176244
599 changed files with 83198 additions and 11394 deletions

View file

@ -90,6 +90,7 @@
"@ff-labs/fff-bun": "0.9.4",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8",
"id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
"22e57fed-b9b8-4e94-a3b4-f94bece680a8"
],
"ddl": [
{
@ -526,6 +526,16 @@
"entityType": "columns",
"table": "event"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,

View file

@ -1,7 +1,7 @@
export * as AccountV2 from "./account"
import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
import type { HttpClientError } from "effect/unstable/http"
export const ID = Schema.String.pipe(Schema.brand("AccountID"))
export type ID = Schema.Schema.Type<typeof ID>

View file

@ -1,12 +1,11 @@
export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
@ -17,8 +16,6 @@ export type ProviderRecord = {
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = Catalog.Event
type Data = {
@ -65,7 +62,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
@ -159,13 +155,6 @@ const layer = Layer.effect(
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {})
}),
})
@ -294,4 +283,4 @@ const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })

View file

@ -3,18 +3,19 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { EventV2 } from "./event"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp"
@ -22,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigVcs } from "./config/vcs"
import { ConfigWatcher } from "./config/watcher"
import { ConfigV1 } from "./v1/config/config"
@ -101,13 +103,12 @@ export class Info extends Schema.Class<Info>("Config.Info")({
description: "Named local directories or Git repositories available as external context",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
description: "Ordered plugin enablement directives and external package declarations",
}),
vcs: ConfigVcs.Info.pipe(Schema.optional).annotate({
description:
"Plugin-provided VCS backends keyed by type; detection markers are only honored in global configuration",
}),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
@ -143,7 +144,8 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const policy = yield* Policy.Service
const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const names = ["opencode.json", "opencode.jsonc"]
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
@ -152,9 +154,10 @@ const layer = Layer.effect(
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
if (errors.length) return
const info = Option.getOrUndefined(
@ -175,45 +178,78 @@ const layer = Layer.effect(
]
})
const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
const discovered = locationIsGlobal
? []
: yield* fs
.up({
targets: [".opencode", ...names.toReversed()],
start: location.directory,
stop: location.project.directory,
})
.pipe(Effect.orDie)
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered = locationIsGlobal
? []
: yield* fs
.up({
targets: [".opencode", ...names.toReversed()],
start: location.directory,
stop: location.project.directory,
})
.pipe(Effect.orDie)
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return {
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories,
files: directPaths,
}
})
const initial = yield* discover()
let configs = initial.entries
const updates = yield* PubSub.unbounded<Watcher.Update>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const targets = (snapshot: typeof initial) => [
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
...snapshot.files
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Apply general settings first and more specific settings last:
// global config, project files, then `.opencode` files.
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(
configs
.filter((config): config is Document => config.type === "document")
.toReversed()
.flatMap((config) => config.info.experimental?.policies ?? []),
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
for (const [key, stop] of subscriptions) {
if (next.has(key)) continue
yield* stop
subscriptions.delete(key)
}
for (const [key, target] of next) {
if (subscriptions.has(key)) continue
const fiber = yield* watcher.subscribe(target).pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
subscriptions.set(key, Fiber.interrupt(fiber))
}
})
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({
entries: Effect.fn("Config.entries")(function* () {
@ -226,5 +262,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
})

View file

@ -1,18 +0,0 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export class Policy extends Schema.Class<Policy>("ConfigV2.Experimental.Policy")({
...PolicyV2.Info.fields,
action: PolicyAction,
}) {}
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: Policy.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -1,8 +1,8 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
@ -34,68 +34,79 @@ const agentKeys = new Set([
])
export const Plugin = define({
id: "config-agent",
id: "opencode.config.agent",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: yield* load() }
yield* ctx.agent.transform((draft) => {
const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
for (const document of loaded.documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
}
}),
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

View file

@ -1,8 +1,8 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
@ -13,38 +13,49 @@ import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = define({
id: "config-command",
id: "opencode.config.command",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
yield* ctx.command.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: yield* load() }
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}),
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

View file

@ -1,134 +0,0 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
const PluginPackage = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.String),
module: Schema.optional(Schema.String),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* fs
.glob("{plugin,plugins}/*", {
cwd: entry.path,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(
Effect.flatMap((items) =>
Effect.filter(items, (item) => fs.isDir(item), {
concurrency: "unbounded",
}),
),
Effect.orElseSucceed(() => []),
)
const packages = yield* Effect.forEach(
directories.sort(),
(directory) => resolvePackageEntrypoint(fs, directory),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
files.sort()
for (const file of files) configured.push({ package: file })
for (const file of packages) configured.push({ package: file })
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
})
}),
})
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
Effect.catch(() => Effect.succeed(undefined)),
)
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
return yield* Effect.forEach(entries, (entry) => {
if (!entry) return Effect.succeed(undefined)
const file = path.resolve(directory, entry)
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
})

View file

@ -1,116 +1,123 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
export const Plugin = define({
id: "config-provider",
id: "opencode.config.provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
}),
)
}
})
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.settings, item.request.settings)
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
yield* ctx.catalog.transform((catalog) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(loaded.entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.settings, item.request.settings)
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}),
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

View file

@ -1,8 +1,8 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Reference } from "../../reference"
@ -11,45 +11,52 @@ import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = define({
id: "core/config-reference",
id: "opencode.config.reference",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
yield* ctx.reference.transform(
Effect.fn(function* (draft) {
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
for (const [name, source] of entries) draft.add(name, source)
}),
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

View file

@ -1,8 +1,8 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
@ -10,41 +10,49 @@ import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = define({
id: "config-skill",
id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
yield* ctx.skill.transform(
Effect.fn(function* (draft) {
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
}),
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.skill.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

View file

@ -0,0 +1,85 @@
export * as ConfigVariable from "./variable"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "../fs-util"
import { InvalidError } from "../v1/config/error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
const text = input.text.replace(
/\{env:([^}]+)\}/g,
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
)
if (!text.includes("{file:")) return text
return yield* substituteFiles(input, text)
})
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
let out = ""
let cursor = 0
for (const match of matches) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
cursor = index + token.length
}
return out + text.slice(cursor)
})

View file

@ -106,8 +106,7 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
timestamp: yield* DateTime.now,
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
})
if (patch) {

View file

@ -1,26 +1,17 @@
import type * as Arr from "effect/Array"
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
import * as NodePath from "@effect/platform-node/NodePath"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import type * as Scope from "effect/Scope"
import * as Sink from "effect/Sink"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import type { NonEmptyReadonlyArray } from "effect/Array"
import { NodeFileSystem, NodePath, NodeSink, NodeStream } from "@effect/platform-node"
import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect"
import type { Scope } from "effect"
import { ChildProcess } from "effect/unstable/process"
import {
ChildProcessSpawner,
ExitCode,
make as makeSpawner,
make,
makeHandle,
ProcessId,
type ChildProcessHandle,
} from "effect/unstable/process/ChildProcessSpawner"
// ast-grep-ignore: no-star-import
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
@ -71,7 +62,7 @@ const flatten = (command: ChildProcess.Command) => {
if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands
return {
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
commands: [head, ...tail] as NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts,
}
}
@ -96,7 +87,7 @@ const toPlatformError = (
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
export const make = Effect.gen(function* () {
const makeCrossSpawnSpawner = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@ -494,12 +485,12 @@ export const make = Effect.gen(function* () {
},
)
return makeSpawner(spawnCommand)
return make(spawnCommand)
})
const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
makeCrossSpawnSpawner,
)
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })

View file

@ -1,7 +1,7 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
import { layer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
@ -19,7 +19,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
const layer = Layer.effect(
const databaseLayer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
@ -37,7 +37,7 @@ const layer = Layer.effect(
)
export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
return databaseLayer.pipe(Layer.provide(layer({ filename })))
}
export function path() {

View file

@ -41,5 +41,8 @@ export const migrations = (
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -22,7 +22,7 @@ export function apply(db: Database) {
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table")
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* schema.up(tx)

View file

@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703090000_reset_v2_event_rename_sweep",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
// `created` column is added by the generated 20260703181610_event_created_column
// migration, which runs after this wipe (NOT NULL without default is safe on the
// emptied table).
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703181610_event_created_column",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703190000_reset_v2_shell_event_payloads",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -1,5 +1,6 @@
import nodePath from "path"
import { customType } from "drizzle-orm/sqlite-core"
import { Schema } from "effect"
import { AbsolutePath } from "../schema"
function storagePath(input: string) {
@ -74,6 +75,8 @@ export const pathColumn = customType<{
},
})
const decodeAbsoluteArray = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String)))
export const absoluteArrayColumn = customType<{
data: AbsolutePath[]
driverData: string
@ -86,6 +89,6 @@ export const absoluteArrayColumn = customType<{
return JSON.stringify(input.map(absolute))
},
fromDriver(input) {
return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
return decodeAbsoluteArray(input).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
},
})

View file

@ -81,6 +81,7 @@ export default {
\`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL,
\`created\` integer NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE

View file

@ -1,18 +1,11 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
type TypeId = typeof TypeId
interface SqliteClient extends Client.SqlClient {
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly export: Effect.Effect<Uint8Array, SqlError>
@ -57,7 +50,7 @@ const make = (options: Config) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers))
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
} catch (cause) {
@ -73,7 +66,7 @@ const make = (options: Config) =>
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers))
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
} catch (cause) {
@ -130,7 +123,7 @@ const make = (options: Config) =>
})
const client = Object.assign(
(yield* Client.make({
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
@ -166,7 +159,7 @@ const nativeLayer = (config: Config) =>
}),
)
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config))
const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,

View file

@ -1,18 +1,11 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
type TypeId = typeof TypeId
interface SqliteClient extends Client.SqlClient {
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
@ -56,7 +49,7 @@ const make = (options: Config) =>
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>)
} catch (cause) {
@ -71,7 +64,7 @@ const make = (options: Config) =>
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers))
statement.setReturnArrays(true)
try {
return Effect.succeed(
@ -124,7 +117,7 @@ const make = (options: Config) =>
})
const client = Object.assign(
(yield* Client.make({
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
@ -161,7 +154,7 @@ const nativeLayer = (config: Config) =>
}),
)
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config))
const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,

View file

@ -227,10 +227,10 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
if (existing && existing.implementation !== node.implementation) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, node)
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))
return group([])
}
if (node.kind === "unbound") {

View file

@ -0,0 +1,24 @@
export * as EventLogger from "./event-logger"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "./effect/app-node"
import { EventV2 } from "./event"
const Types = new Set([
"agent.updated",
"catalog.updated",
"command.updated",
"config.updated",
])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
)
export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] })

View file

@ -1,6 +1,6 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
@ -55,6 +55,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
export type SerializedEvent = {
readonly id: ID
readonly type: string
readonly created?: DateTime.Utc
readonly seq: number
readonly aggregateID: string
readonly data: Record<string, unknown>
@ -81,6 +82,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
}
return {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data),
@ -92,8 +94,9 @@ export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberO
{ capacity: Schema.Int },
) {}
export const define = Event.define
export const versionedType = Event.versionedType
export const durable = Event.durable
export const ephemeral = Event.ephemeral
export interface PublishOptions {
readonly id?: ID
@ -157,15 +160,22 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const liveBounded = (events: Interface, capacity: number) =>
export const liveBounded = (
events: Interface,
options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean },
) =>
Effect.gen(function* () {
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(options.capacity)
const unsubscribe = yield* events.listen((event) =>
Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
),
),
options.accept && !options.accept(event)
? Effect.void
: Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted
? Effect.void
: Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid),
),
),
)
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
return Stream.fromQueue(queue)
@ -294,6 +304,7 @@ export const layerWith = (options?: LayerOptions) =>
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
@ -365,6 +376,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
type: versionedType(definition.type, durable.version),
data: encoded,
},
@ -470,6 +482,7 @@ export const layerWith = (options?: LayerOptions) =>
definition,
{
id: options?.id ?? ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
@ -493,6 +506,7 @@ export const layerWith = (options?: LayerOptions) =>
} else {
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload
@ -569,12 +583,32 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
}
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
Stream.unwrap(
Effect.serviceOption(Location.Service).pipe(
Effect.map((location) =>
Option.match(location, {
onNone: () => stream,
onSome: (location) =>
stream.pipe(
Stream.filter(
(event) =>
!event.location ||
(event.location.directory === location.directory &&
event.location.workspaceID === location.workspaceID),
),
),
}),
),
),
)
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live)
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = (
aggregateID: string,
@ -609,6 +643,7 @@ export const layerWith = (options?: LayerOptions) =>
return [
decodeSerializedEvent({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,

View file

@ -15,6 +15,7 @@ export const EventTable = sqliteTable(
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(),
created: integer().notNull(),
type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
},

View file

@ -45,7 +45,7 @@ const FILES = [
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {

View file

@ -0,0 +1,90 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (!home) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})

View file

@ -3,26 +3,20 @@ export * as Watcher from "./watcher"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import os from "os"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = FileSystemWatcher.Event
export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
@ -42,107 +36,134 @@ function getBackend() {
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
export const hasNativeBinding = () => !!watcher()
export type Update = ParcelWatcher.Event
export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
const backend = getBackend()
const location = yield* Location.Service
if (path.resolve(location.directory) === path.resolve(os.homedir())) {
yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory })
return Service.of({})
}
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
const native = watcher()
if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
return Service.of({ subscribe: () => Stream.empty })
}
const w = watcher()
if (!w) return Service.of({})
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
}
type Entry = {
readonly pubsub: PubSub.PubSub<Update>
readonly subscription: { readonly unsubscribe: () => Promise<void> }
refs: number
}
const entries = new Map<string, Entry>()
const locks = KeyedMutex.makeUnsafe<string>()
const subscribe = (directory: string, ignore: string[]) => {
const pending = w.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.tap((subscription) =>
Effect.sync(() => subscriptions.push(subscription)).pipe(
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })),
),
),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
const scope = yield* Scope.Scope
const target = path.resolve(input.path)
const directory = input.type === "file" ? path.dirname(target) : target
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
const id = JSON.stringify([input.type, target, ignore])
const pubsub = yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = entries.get(id)
if (existing) {
existing.refs++
return existing.pubsub
}
const pubsub = yield* PubSub.unbounded<Update>()
const subscription = yield* input.type === "file"
? Effect.sync(() => {
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== target) return
PubSub.publishUnsafe(pubsub, {
path: target,
type: "update",
} satisfies Update)
})
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
)
}
return { unsubscribe: () => Promise.resolve(subscription.close()) }
})
: subscribeDirectory(native, backend, directory, ignore, pubsub)
if (subscription) {
entries.set(id, { pubsub, subscription, refs: 1 })
yield* Effect.logInfo("watcher started", {
path: target,
type: input.type,
backend: input.type === "file" ? "node" : backend,
ignores: ignore.length,
})
return pubsub
}
yield* PubSub.shutdown(pubsub)
return pubsub
}),
)
}
const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
)
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
yield* Scope.addFinalizer(
scope,
locks.withLock(id)(
Effect.gen(function* () {
const entry = entries.get(id)
if (!entry) return
entry.refs--
if (entry.refs > 0) return
entries.delete(id)
yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
yield* PubSub.shutdown(entry.pubsub)
yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
}),
),
)
}),
),
return pubsub
})
const subscribe = (input: WatchInput) =>
Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
return Service.of({ subscribe })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,
backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: string[],
pubsub: PubSub.PubSub<Update>,
) {
if (!native || !backend) {
return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe(
Effect.as(undefined),
)
}
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) PubSub.publishUnsafe(pubsub, update)
}
const pending = native.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", {
directory,
cause: Cause.pretty(cause),
}).pipe(Effect.as(undefined))
}),
)
}

View file

@ -52,6 +52,9 @@ export const Flag = {
get OPENCODE_EXPERIMENTAL_REFERENCES() {
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
},
get CODEMODE_ENABLED() {
return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED")
},
get OPENCODE_TUI_CONFIG() {
return process.env["OPENCODE_TUI_CONFIG"]
},

View file

@ -21,6 +21,7 @@ export type When = Form.When
export const State = Form.State
export type State = typeof State.Type
export type TerminalState = Exclude<State, { readonly status: "pending" }>
export const Answer = Form.Answer
export type Answer = typeof Answer.Type
@ -78,7 +79,7 @@ export interface ListInput {
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
@ -91,17 +92,21 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fo
interface Entry {
readonly form: Info
readonly state: State
readonly deferred: Deferred.Deferred<State>
readonly deferred: Deferred.Deferred<TerminalState>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const forms = yield* Cache.makeWith<ID, Entry>(() => Effect.die("Form cache must be used via set/getSuccess, never get"), {
capacity: Number.MAX_SAFE_INTEGER,
timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION),
})
const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
capacity: Number.MAX_SAFE_INTEGER,
timeToLive: (exit) =>
Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION,
},
)
const find = Effect.fn("Form.find")(function* (id: ID) {
return yield* Cache.getSuccess(forms, id).pipe(
@ -131,11 +136,13 @@ export const layer = Layer.effect(
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
}
const form: Info =
input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url }
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: { ...base, mode: "url", url: input.url }
const entry: Entry = {
form,
state: { status: "pending" },
deferred: yield* Deferred.make<State>(),
deferred: yield* Deferred.make<TerminalState>(),
}
yield* Cache.set(forms, id, entry)
yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
@ -149,7 +156,9 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const form = yield* create(input)
const entry = yield* find(form.id).pipe(Effect.orDie)
return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id))))
return yield* restore(Deferred.await(entry.deferred)).pipe(
Effect.onInterrupt(() => Effect.ignore(cancel(form.id))),
)
}),
),
)
@ -177,7 +186,7 @@ export const layer = Layer.effect(
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
const invalid = validateAnswer(entry.form, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
const next: State = { status: "answered", answer: input.answer }
const next: TerminalState = { status: "answered", answer: input.answer }
yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
yield* Cache.set(forms, input.id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
@ -190,7 +199,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const entry = yield* find(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: State = { status: "cancelled" }
const next: TerminalState = { status: "cancelled" }
yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
yield* Cache.set(forms, id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
@ -301,7 +310,8 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined
return `Form field has invalid pattern: ${field.key}`
}
}
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}`
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
return `Expected email for form field: ${field.key}`
if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}`
if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}`
@ -324,8 +334,10 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined
if (field.type === "multiselect") {
if (!isStringArray(value)) return `Expected string array for form field: ${field.key}`
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}`
if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}`
if (field.minItems !== undefined && value.length < field.minItems)
return `Too few selections for form field: ${field.key}`
if (field.maxItems !== undefined && value.length > field.maxItems)
return `Too many selections for form field: ${field.key}`
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) {
return `Invalid option for form field: ${field.key}`
}

View file

@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path"
import path, { dirname, isAbsolute, join, relative, sep } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { readdir } from "fs/promises"
import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
@ -38,6 +38,7 @@ export namespace FSUtil {
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly resolve: (path: string) => Effect.Effect<string>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
@ -49,7 +50,9 @@ export namespace FSUtil {
export const use = serviceUse(Service)
const layer = Layer.effect(
// Exported so simulation can wrap this layer and override the methods that
// bypass the injected FileSystem (readDirectoryEntries, glob, globUp).
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@ -77,7 +80,7 @@ export namespace FSUtil {
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
return yield* Effect.tryPromise({
try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
const entries = await readdir(dirPath, { withFileTypes: true })
return entries.map(
(e): DirEntry => ({
name: e.name,
@ -89,6 +92,14 @@ export namespace FSUtil {
})
})
const resolve = Effect.fn("FileSystem.resolve")(function* (input: string) {
const resolved = path.resolve(windowsPath(input))
return yield* fs.realPath(resolved).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)),
Effect.orDie,
)
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return yield* Effect.try({
@ -187,6 +198,7 @@ export namespace FSUtil {
isDir,
isFile,
readDirectoryEntries,
resolve,
readJson,
writeJson,
ensureDir,
@ -209,7 +221,7 @@ export namespace FSUtil {
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
const resolved = path.resolve(windowsPath(p))
try {
return realpathSync.native(resolved)
} catch {
@ -227,7 +239,7 @@ export namespace FSUtil {
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
const resolved = path.resolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {

View file

@ -1,4 +1,4 @@
import { create as createIdentifier } from "@opencode-ai/schema/identifier"
import { create } from "@opencode-ai/schema/identifier"
const prefixes = {
job: "job",
@ -23,7 +23,7 @@ export function descending(prefix: keyof typeof prefixes, given?: string) {
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
return createID(prefixes[prefix], direction)
}
if (!given.startsWith(prefixes[prefix])) {
@ -32,10 +32,12 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as
return given
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
return prefix + "_" + createIdentifier(direction === "descending", timestamp)
function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
return prefix + "_" + create(direction === "descending", timestamp)
}
export { createID as create }
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]

View file

@ -43,22 +43,24 @@ const layer = Layer.effect(
})
const observe = Effect.fn("InstructionContext.observe")(function* () {
const start = FSUtil.resolve(location.directory)
const stop = FSUtil.resolve(location.project.directory)
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const fromProject = relative(stop, start)
const insideProject =
fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
(Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
})
).map(FSUtil.resolve),
yield* Effect.forEach(
Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
}),
fs.resolve,
),
)
const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered])
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>

View file

@ -406,7 +406,7 @@ const layer = Layer.effect(
.get()
.integrations.get(input.integrationID)
?.methods.some((method) => method.type === "key")
if (!method) return yield* Effect.die(`Key method not found: ${input.integrationID}`)
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
@ -418,7 +418,7 @@ const layer = Layer.effect(
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) {
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`))
}
const attemptScope = yield* Scope.fork(scope)
const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
@ -475,7 +475,7 @@ const layer = Layer.effect(
attempt: {
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID)
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`)
if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${attemptID}`))
if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
}
@ -488,12 +488,13 @@ const layer = Layer.effect(
if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
})
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`)
if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`))
if (attempt.status !== "pending") return
if (attempt.authorization.mode === "code" && input.code === undefined) {
return yield* new CodeRequiredError({ attemptID: input.attemptID })
}
if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`)
if (attempt.completing)
return yield* Effect.die(new Error(`OAuth attempt already completing: ${input.attemptID}`))
const callback =
attempt.authorization.mode === "auto"
? attempt.authorization.callback

View file

@ -5,31 +5,38 @@ import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { Config } from "./config"
import { LayerNode } from "./effect/layer-node"
import { Node } from "./effect/app-node"
import { makeLocationNode, Node } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
import { EventV2 } from "./event"
import { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { FSUtil } from "./fs-util"
import { Generate } from "./generate"
import { Form } from "./form"
import { Watcher } from "./filesystem/watcher"
import { Global } from "./global"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Image } from "./image"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index"
import { ModelsDev } from "./models-dev"
import { Npm } from "./npm"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { Project } from "./project"
import { PluginRuntime } from "./plugin/runtime"
import { SdkPlugins } from "./plugin/sdk"
import { PluginSupervisor } from "./plugin/supervisor"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
import { Ripgrep } from "./ripgrep"
import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title"
@ -41,20 +48,56 @@ import { InstructionContext } from "./instruction-context"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SessionContextEntry } from "./session/context-entry"
import { SessionInstructions } from "./session/instructions"
import { BuiltInTools } from "./tool/builtins"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { WebSearchTool } from "./tool/websearch"
import { ToolOutputStore } from "./tool-output-store"
import { Vcs } from "./vcs"
import { VcsBackends } from "./vcs/backends"
export { LocationServiceMap } from "./location-service-map"
export const locationServices = LayerNode.group([
Project.node,
const pluginSupervisorNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: PluginSupervisor.layer,
deps: [
PluginV2.node,
SdkPlugins.node,
AgentV2.node,
Catalog.node,
CommandV2.node,
Config.node,
EventV2.node,
FileMutation.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
PermissionV2.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
SessionTodo.node,
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
VcsBackends.node,
WebSearchTool.configNode,
],
})
const locationServiceNodes = [
Location.node,
Policy.node,
Config.node,
AgentV2.node,
CommandV2.node,
@ -63,12 +106,11 @@ export const locationServices = LayerNode.group([
Catalog.node,
AISDK.node,
PluginV2.node,
PluginInternal.node,
pluginSupervisorNode,
ProjectCopy.node,
ProjectCopy.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Watcher.node,
Pty.node,
Shell.node,
SkillV2.node,
@ -90,7 +132,6 @@ export const locationServices = LayerNode.group([
QuestionV2.node,
Generate.node,
ReadToolFileSystem.node,
BuiltInTools.node,
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
@ -100,7 +141,11 @@ export const locationServices = LayerNode.group([
SessionRunnerLLM.node,
VcsBackends.node,
Vcs.node,
])
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
@ -112,15 +157,21 @@ export function buildLocationServiceMap(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("booting location services", {
Effect.logInfo("location services booted", {
directory: ref.directory,
workspaceID: ref.workspaceID,
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),

View file

@ -46,7 +46,11 @@ const TolerantListPromptsResult = ListPromptsResultSchema.extend({
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {}
}) {
override get message() {
return `MCP server requires authentication: ${this.server}`
}
}
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
server: Schema.String,
@ -57,6 +61,7 @@ export interface ToolDefinition {
readonly name: string
readonly description: string | undefined
readonly inputSchema: unknown
readonly outputSchema: unknown
}
export interface PromptDefinition {
@ -231,6 +236,7 @@ export const connect = Effect.fnUntraced(function* (
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: "outputSchema" in tool ? tool.outputSchema : undefined,
}))
}),
prompts: () =>

View file

@ -1,6 +1,7 @@
export * as McpGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Flag } from "../flag/flag"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@ -17,6 +18,11 @@ type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
...(Flag.CODEMODE_ENABLED
? [
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
]
: []),
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
@ -64,15 +70,17 @@ export const layer = Layer.effect(
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return SystemContext.empty
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return SystemContext.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
// Hide a server only when every tool it contributes is wholly denied for this agent.
// Instructions are useful only when this agent can reach at least one server tool.
const visible = instructions
.filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return (
owned.length === 0 ||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",

View file

@ -42,6 +42,7 @@ export class Tool extends Schema.Class<Tool>("MCP.Tool")({
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
inputSchema: Schema.Unknown.pipe(Schema.optional),
outputSchema: Schema.Unknown.pipe(Schema.optional),
}) {}
export const ToolResultContent = Schema.Union([
@ -127,7 +128,11 @@ export class ResourceContent extends Schema.Class<ResourceContent>("MCP.Resource
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
server: ServerName,
}) {}
}) {
override get message() {
return `MCP server not found: ${this.server}`
}
}
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
server: ServerName,
@ -201,7 +206,7 @@ export const layer = Layer.effect(
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), {
config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "disconnected" },
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}
@ -387,7 +392,13 @@ export const layer = Layer.effect(
} satisfies MCPClient.ElicitationHandler
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
new Tool({
server,
name: def.name,
description: def.description,
inputSchema: def.inputSchema,
outputSchema: def.outputSchema,
})
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
new Prompt({

View file

@ -47,7 +47,7 @@ const Cost = Schema.Struct({
const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.String),
values: Schema.Array(Schema.Union([Schema.String, Schema.Null])),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
@ -67,7 +67,7 @@ export const Model = Schema.Struct({
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.Boolean,
temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.Boolean,
interleaved: Schema.optional(
Schema.Union([
@ -125,6 +125,10 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider>
const Providers = Schema.Record(Schema.String, Provider)
const decodeProviders = Schema.decodeUnknownEffect(Schema.fromJsonString(Providers))
const decodeProvidersUnknown = Schema.decodeUnknownEffect(Providers)
export const Event = ModelsDev.Event
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
@ -176,6 +180,7 @@ const layer = Layer.effect(
})
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
Effect.flatMap(decodeProvidersUnknown),
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
@ -186,11 +191,17 @@ const layer = Layer.effect(
}
return Effect.succeed(undefined)
}),
Effect.map((v) => v as Record<string, Provider> | undefined),
)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
).pipe(
Effect.flatMap((snapshot) =>
snapshot === undefined ? Effect.succeed(undefined) : decodeProvidersUnknown(snapshot),
),
Effect.catch((cause) =>
Effect.logWarning("bundled models snapshot failed schema decode", { cause }).pipe(Effect.as(undefined)),
),
)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
@ -221,7 +232,7 @@ const layer = Layer.effect(
return yield* fetchAndWrite()
}),
)
return JSON.parse(text) as Record<string, Provider>
return yield* decodeProviders(text)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)

View file

@ -8,6 +8,12 @@ import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe(
@ -19,6 +25,6 @@ export const layer = Layer.unwrap(
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)
).pipe(Layer.catchCause(() => local))
export const node = LayerNode.make({ name: "observability", layer, deps: [] })

View file

@ -1,7 +1,7 @@
export * as PermissionV2 from "./permission"
import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { EventV2 } from "./event"
import { Location } from "./location"
@ -11,7 +11,9 @@ import { SessionStore } from "./session/store"
import { Wildcard } from "./util/wildcard"
import { PermissionSaved } from "./permission/saved"
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
export { Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const ID = Permission.ID
@ -90,12 +92,12 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly get: (id: ID) => Effect.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect<ReadonlyArray<Request>>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}
@ -108,7 +110,7 @@ interface Pending {
const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
Effect.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const agents = yield* AgentV2.Service
@ -116,28 +118,25 @@ const layer = Layer.effect(
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
yield* EffectRuntime.addFinalizer(() =>
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true,
}).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const savedRules = EffectRuntime.fnUntraced(function* () {
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
)
})
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (
sessionID: SessionV2.ID,
agentID?: AgentV2.ID,
) {
const configured = Effect.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID, agentID?: AgentV2.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
@ -152,7 +151,7 @@ const layer = Layer.effect(
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) {
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
@ -174,29 +173,30 @@ const layer = Layer.effect(
}
const create = (request: Request, agent?: AgentV2.ID) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, agent, deferred }
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
yield* events
.publish(Event.Asked, request)
.pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))))
.pipe(Effect.onError(() => Effect.sync(() => pending.delete(request.id))))
return item
}),
)
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
const ask = Effect.fn("PermissionV2.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
})
const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) =>
EffectRuntime.uninterruptibleMask((restore) =>
EffectRuntime.gen(function* () {
const assert = Effect.fn("PermissionV2.assert")((input: AssertInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
@ -206,8 +206,8 @@ const layer = Layer.effect(
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
@ -216,9 +216,9 @@ const layer = Layer.effect(
),
)
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
const reply = Effect.fn("PermissionV2.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, {
@ -261,7 +261,7 @@ const layer = Layer.effect(
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
@ -284,15 +284,15 @@ const layer = Layer.effect(
),
)
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
const list = Effect.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)
})
const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) {
const get = Effect.fn("PermissionV2.get")(function* (id: ID) {
return pending.get(id)?.request
})
const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})

View file

@ -1,16 +1,15 @@
export * as PluginV2 from "./plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { EventV2 } from "./event"
import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { Location } from "./location"
import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime"
@ -21,16 +20,8 @@ import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks"
import { VcsBackends } from "./vcs/backends"
export const ID = Plugin.ID
export type ID = typeof ID.Type
export const Info = Plugin.Info
export type Info = Plugin.Info
export const Event = Plugin.Event
export interface Interface {
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly wait: (id: ID) => Effect.Effect<void>
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
}
@ -40,110 +31,83 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>()
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
const failures = new Map<ID, Exit.Exit<void, never>>()
let host: Parameters<PluginDefinition["effect"]>[0]
const active = new Map<typeof ID.Type, Scope.Closeable>()
const lock = Semaphore.makeUnsafe(1)
let generation: readonly { readonly id: typeof ID.Type; readonly version?: string }[] | undefined = []
let host: Parameters<Plugin["effect"]>[0]
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly { readonly plugin: Plugin; readonly version?: string }[],
) {
const definitions = plugins.map((entry) => ({
...entry.plugin,
id: ID.make(entry.plugin.id),
...(entry.version === undefined ? {} : { version: entry.version }),
}))
const ids = new Set<typeof ID.Type>()
for (const definition of definitions) {
if (ids.has(definition.id)) return yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`))
ids.add(definition.id)
}
yield* locks.withLock(id)(
Effect.sync(() => {
loading.add(id)
failures.delete(id)
}).pipe(
Effect.andThen(
State.batch(
Effect.gen(function* () {
const existing = active.get(id)
active.delete(id)
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
yield* lock.withPermit(
Effect.gen(function* () {
if (
generation !== undefined &&
generation.length === definitions.length &&
generation.every(
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
)
) {
return
}
generation = undefined
const exit = yield* State.batch(
Effect.gen(function* () {
const scopes = Array.from(active.values()).toReversed()
active.clear()
const inherit = yield* State.inherit()
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
for (const definition of definitions) {
const child = yield* Scope.fork(scope)
yield* effect(host).pipe(
Scope.provide(child),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe(
inherit,
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": definition.id } }),
Effect.andThen(events.publish(Event.Added, { id: definition.id })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
yield* events.publish(Event.Added, { id })
active.set(id, child)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
discard: true,
})
waiters.delete(id)
}),
),
),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) return Effect.void
failures.set(id, exit)
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
discard: true,
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
}),
Effect.ensuring(Effect.sync(() => loading.delete(id))),
),
)
})
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
yield* locks.withLock(id)(
State.batch(
Effect.gen(function* () {
const current = active.get(id)
active.delete(id)
failures.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}),
),
)
})
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
const waiter = yield* Deferred.make<void>()
const pending = yield* locks.withLock(id)(
Effect.sync(() => {
if (active.has(id)) return false
const failure = failures.get(id)
if (failure) return failure
const current = waiters.get(id) ?? new Set()
current.add(waiter)
waiters.set(id, current)
return true
}),
)
if (!pending) return
if (typeof pending !== "boolean") return yield* pending
yield* Deferred.await(waiter).pipe(
Effect.ensuring(
locks.withLock(id)(
Effect.sync(() => {
const current = waiters.get(id)
current?.delete(waiter)
if (current?.size === 0) waiters.delete(id)
if (Exit.isFailure(loaded)) return loaded
active.set(definition.id, child)
}
return Exit.void
}),
),
),
)
if (Exit.isFailure(exit)) return yield* exit
generation = definitions.map((definition) => ({
id: definition.id,
...(definition.version === undefined ? {} : { version: definition.version }),
}))
yield* events.publish(Event.Updated, {})
}),
)
})
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
active.clear()
generation = []
yield* State.batch(Scope.close(scope, exit))
}),
)
const service = Service.of({
add,
remove,
wait,
activate,
list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id }))
}),

View file

@ -1,7 +1,7 @@
export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { AgentV2 } from "../agent"
import { Global } from "../global"
@ -100,7 +100,7 @@ Rules:
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
export const Plugin = define({
id: "agent",
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory

View file

@ -1,13 +1,13 @@
export * as CommandPlugin from "./command"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { Location } from "../location"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = define({
id: "command",
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
yield* ctx.command.transform((draft) => {

View file

@ -1,12 +1,14 @@
export * as PluginHost from "./host"
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Credential } from "../credential"
import { EventV2 } from "../event"
import { Integration } from "../integration"
import { Location } from "../location"
import { ModelV2 } from "../model"
@ -22,12 +24,12 @@ import { VcsBackends } from "../vcs/backends"
import { WorkspaceV2 } from "../workspace"
const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
@ -42,7 +44,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
workspaceID: location.workspaceID,
project: location.project,
})
const locationRef = (input?: Parameters<Interface["agent"]["list"]>[0]) =>
const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) =>
input?.location === undefined
? undefined
: Location.Ref.make({
@ -54,6 +56,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
const isCurrentLocation = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
return {
options: {},
@ -65,15 +69,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) =>
agents.transform((draft) => {
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
remove: (id) => draft.remove(AgentV2.ID.make(id)),
}),
),
})
}),
},
aisdk: {
sdk: (callback) =>
@ -104,9 +108,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
catalog: {
provider: {
list: () => response(catalog.provider.available()),
get: (input) =>
catalog.provider
.get(ProviderV2.ID.make(input.providerID))
.pipe(
Effect.flatMap((provider) =>
provider === undefined
? Effect.fail(new Error(`Provider not found: ${input.providerID}`))
: response(Effect.succeed(provider)),
),
),
},
model: {
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) =>
catalog.transform((draft) => {
callback({
provider: {
list: () => mutable(draft.provider.list()),
@ -127,14 +148,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
},
},
}),
),
})
}),
},
command: {
list: () => response(commands.list()),
reload: commands.reload,
transform: commands.transform,
transform: (callback) =>
commands.transform((draft) => {
callback(draft)
}),
},
event: {
subscribe: () => events.live().pipe(Stream.filter(EventManifest.isServer)),
},
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
label: input.label,
}),
connectOauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
inputs: input.inputs,
label: input.label,
}),
),
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
attemptComplete: (input) =>
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
@ -144,7 +193,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
},
transform: (callback) =>
integration.transform((draft) =>
integration.transform((draft) => {
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Integration.ID.make(id))),
@ -221,36 +270,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
}),
),
})
}),
},
plugin: {
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
list: () => response(plugin.list()),
},
reference: {
list: () => response(reference.list()),
reload: reference.reload,
transform: (callback) =>
reference.transform((draft) =>
reference.transform((draft) => {
callback({
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove,
list: draft.list,
}),
),
})
}),
},
skill: {
list: () => response(skill.list()),
reload: skill.reload,
transform: (callback) =>
skill.transform((draft) =>
skill.transform((draft) => {
callback({
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
list: draft.list,
}),
),
})
}),
},
tool: {
register: (input) => tools.register(input),
register: (input, options) => tools.register(input, options),
execute: {
before: (callback) =>
toolHooks.hook.before((event) => {
@ -310,5 +360,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
command: runtime.session.command,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
},
} satisfies Interface
} satisfies PluginContext
})

View file

@ -1,168 +1,170 @@
export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer, Scope } from "effect"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigExternalPlugin } from "../config/plugin/external"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { Form } from "../form"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { Image } from "../image"
import { Integration } from "../integration"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { State } from "../state"
import { ToolRegistry } from "../tool/registry"
import { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { QuestionTool } from "../tool/question"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ReadTool } from "../tool/read"
import { ShellTool } from "../tool/shell"
import { SkillTool } from "../tool/skill"
import { SubagentTool } from "../tool/subagent"
import { TodoWriteTool } from "../tool/todowrite"
import { Tools } from "../tool/tools"
import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch"
import { WriteTool } from "../tool/write"
import { VcsBackends } from "../vcs/backends"
import { HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
import { GlobTool } from "../tool/glob"
import { ShellTool } from "../tool/shell"
import { SubagentTool } from "../tool/subagent"
export type Requirements =
| AgentV2.Service
| Catalog.Service
| CommandV2.Service
| Config.Service
| EventV2.Service
| FileSystem.Service
| FSUtil.Service
| Global.Service
| HttpClient.HttpClient
| Integration.Service
| Location.Service
| LocationMutation.Service
| ModelsDev.Service
| Npm.Service
| PermissionV2.Service
| PluginRuntime.Service
| Reference.Service
| Ripgrep.Service
| Shell.Service
| SkillV2.Service
| Tools.Service
| VcsBackends.Service
export interface Plugin<R = never> {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
}
export function define<R>(plugin: Plugin<R>) {
return plugin
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const sdkPlugins = yield* SdkPlugins.Service
const services = Context.mergeAll(
Context.make(Catalog.Service, yield* Catalog.Service),
Context.make(CommandV2.Service, yield* CommandV2.Service),
Context.make(Integration.Service, yield* Integration.Service),
Context.make(AgentV2.Service, yield* AgentV2.Service),
Context.make(Config.Service, yield* Config.Service),
Context.make(Location.Service, yield* Location.Service),
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
Context.make(Npm.Service, yield* Npm.Service),
Context.make(EventV2.Service, yield* EventV2.Service),
Context.make(FSUtil.Service, yield* FSUtil.Service),
Context.make(FileSystem.Service, yield* FileSystem.Service),
Context.make(Global.Service, yield* Global.Service),
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
Context.make(SkillV2.Service, yield* SkillV2.Service),
Context.make(Reference.Service, yield* Reference.Service),
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
Context.make(Shell.Service, yield* Shell.Service),
Context.make(Tools.Service, yield* Tools.Service),
Context.make(VcsBackends.Service, yield* VcsBackends.Service),
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
)
const add = (input: Plugin<Requirements | Scope.Scope>) =>
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
input.effect(context).pipe(Effect.provide(services)),
)
yield* State.batch(
Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(GlobTool.Plugin)
yield* add(ShellTool.Plugin)
yield* add(SubagentTool.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
// Embedder-contributed plugins are added last so they layer over config.
for (const plugin of sdkPlugins.all()) yield* add(plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
)
export const node = makeLocationNode({
name: "plugin-internal",
layer,
deps: [
Catalog.node,
CommandV2.node,
PluginV2.node,
Integration.node,
AgentV2.node,
Config.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
EventV2.node,
FSUtil.node,
FileSystem.node,
Global.node,
httpClient,
PermissionV2.node,
SkillV2.node,
Reference.node,
Ripgrep.node,
Shell.node,
ToolRegistry.toolsNode,
VcsBackends.node,
PluginRuntime.node,
SdkPlugins.node,
],
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const command = yield* CommandV2.Service
const config = yield* Config.Service
const events = yield* EventV2.Service
const mutation = yield* FileMutation.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const integration = yield* Integration.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const npm = yield* Npm.Service
const permission = yield* PermissionV2.Service
const runtime = yield* PluginRuntime.Service
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const todo = yield* SessionTodo.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
const vcs = yield* VcsBackends.Service
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
Context.make(Catalog.Service, catalog),
Context.make(CommandV2.Service, command),
Context.make(Config.Service, config),
Context.make(EventV2.Service, events),
Context.make(FileMutation.Service, mutation),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(Integration.Service, integration),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(Npm.Service, npm),
Context.make(PermissionV2.Service, permission),
Context.make(PluginRuntime.Service, runtime),
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(SessionTodo.Service, todo),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
Context.make(VcsBackends.Service, vcs),
)
})
type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
AgentPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
ApplyPatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
QuestionTool.Plugin,
ReadTool.Plugin,
ShellTool.Plugin,
SkillTool.Plugin,
SubagentTool.Plugin,
TodoWriteTool.Plugin,
WebFetchTool.Plugin,
WebSearchTool.Plugin,
WriteTool.Plugin,
] as const satisfies readonly InternalPlugin[]
const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
const context = yield* services()
const resolve = (plugins: readonly InternalPlugin[]) =>
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
return {
pre: resolve(pre),
post: resolve(post),
}
})

View file

@ -1,4 +1,4 @@
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
@ -197,68 +197,69 @@ function applyModel(
}
export const ModelsDevPlugin = define({
id: "models-dev",
id: "opencode.models-dev",
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const data = yield* modelsDev.get()
for (const item of Object.values(data)) {
if (item.env.length === 0) continue
const integrationID = item.id
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}),
)
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const data = yield* modelsDev.get()
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.api = item.npm
? {
type: "aisdk",
package: item.npm,
url: item.api,
}
: {
type: "native",
url: item.api,
settings: {},
}
})
const loaded = { data: yield* modelsDev.get() }
yield* ctx.integration.transform((integrations) => {
for (const item of Object.values(loaded.data)) {
if (item.env.length === 0) continue
const integrationID = item.id
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
})
yield* ctx.catalog.transform((catalog) => {
for (const item of Object.values(loaded.data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.api = item.npm
? {
type: "aisdk",
package: item.npm,
url: item.api,
}
: {
type: "native",
url: item.api,
settings: {},
}
})
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, {
name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost),
request: options.provider,
variants,
}),
)
}
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, {
name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost),
request: options.provider,
variants,
}),
)
}
}
}),
)
}
})
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
Stream.runForEach(() =>
modelsDev.get().pipe(
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),

View file

@ -1,16 +1,15 @@
export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope } from "effect"
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope, Stream } from "effect"
// The Effect host hands back this registration shape; mirror it structurally so
// we do not have to alias the Effect package's `Registration` against the Promise one.
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`PluginV2` / `PluginInternal`) can run it unchanged.
* loader (`PluginV2` / `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
@ -31,20 +30,23 @@ export function fromPromise(plugin: Plugin) {
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
const transform =
<Draft>(domain: {
transform: (
callback: (draft: Draft) => Effect.Effect<void> | void,
) => Effect.Effect<HostRegistration, never, Scope.Scope>
transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) =>
(callback: (draft: Draft) => Promise<void> | void) =>
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
(callback: (draft: Draft) => void) =>
register(
domain.transform((draft) => {
callback(draft)
}),
)
const context2: PluginContext = {
options: host.options,
agent: {
list: (input) => run(host.agent.list(input)),
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
@ -55,14 +57,33 @@ export function fromPromise(plugin: Plugin) {
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
provider: {
list: (input) => run(host.catalog.provider.list(input)),
get: (input) => run(host.catalog.provider.get(input)),
},
model: {
list: (input) => run(host.catalog.model.list(input)),
default: (input) => run(host.catalog.model.default(input)),
},
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
command: {
list: (input) => run(host.command.list(input)),
transform: transform(host.command),
reload: () => run(host.command.reload()),
},
event: {
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
},
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
connection: {
@ -71,20 +92,25 @@ export function fromPromise(plugin: Plugin) {
},
},
plugin: {
add: (input) => {
const child = fromPromise(input)
return run(host.plugin.add(child))
},
remove: (id) => run(host.plugin.remove(id)),
list: (input) => run(host.plugin.list(input)),
},
reference: {
list: (input) => run(host.reference.list(input)),
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
skill: {
list: (input) => run(host.skill.list(input)),
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
session: {
create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)),
},
}
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))

View file

@ -31,9 +31,8 @@ import { VenicePlugin } from "./provider/venice"
import { XAIPlugin } from "./provider/xai"
import { ZenmuxPlugin } from "./provider/zenmux"
import type { PluginInternal } from "./internal"
import type { Scope } from "effect"
export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AlibabaPlugin = define({
id: "alibaba",
id: "opencode.provider.alibaba",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
type MantleSDK = {
@ -60,24 +60,22 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
}
export const AmazonBedrockPlugin = define({
id: "amazon-bedrock",
id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (typeof provider.request.body.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.api.url = provider.request.body.endpoint
delete provider.request.body.endpoint
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (typeof provider.request.body.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.api.url = provider.request.body.endpoint
delete provider.request.body.endpoint
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return

View file

@ -1,21 +1,19 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AnthropicPlugin = define({
id: "anthropic",
id: "opencode.provider.anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@ -11,23 +11,21 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
}
export const AzurePlugin = define({
id: "azure",
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue
const configured = item.provider.request.body.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.resourceName = resourceName
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue
const configured = item.provider.request.body.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.resourceName = resourceName
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
@ -56,22 +54,20 @@ export const AzurePlugin = define({
})
export const AzureCognitiveServicesPlugin = define({
id: "azure-cognitive-services",
id: "opencode.provider.azure-cognitive-services",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
})
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return

View file

@ -1,20 +1,18 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CerebrasPlugin = define({
id: "cerebras",
id: "opencode.provider.cerebras",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return

View file

@ -1,10 +1,10 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CloudflareAIGatewayPlugin = define({
id: "cloudflare-ai-gateway",
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,26 +1,24 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "cloudflare-workers-ai",
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (provider.api.url) return
const accountId = resolveAccountId(provider.request.body)
if (accountId) provider.api.url = workersEndpoint(accountId)
})
}),
)
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (provider.api.url) return
const accountId = resolveAccountId(provider.request.body)
if (accountId) provider.api.url = workersEndpoint(accountId)
})
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CoherePlugin = define({
id: "cohere",
id: "opencode.provider.cohere",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const DeepInfraPlugin = define({
id: "deepinfra",
id: "opencode.provider.deepinfra",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,10 +1,10 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
export const DynamicProviderPlugin = define({
id: "dynamic-provider",
id: "opencode.provider.dynamic",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.sdk(

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GatewayPlugin = define({
id: "gateway",
id: "opencode.provider.gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) {
@ -12,19 +12,17 @@ function shouldUseResponses(modelID: string) {
}
export const GithubCopilotPlugin = define({
id: "github-copilot",
id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
}),
)
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return

View file

@ -1,11 +1,11 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({
id: "gitlab",
id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) {
@ -55,35 +55,33 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
}
export const GoogleVertexPlugin = define({
id: "google-vertex",
id: "opencode.provider.google-vertex",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (
item.provider.api.package !== "@ai-sdk/google-vertex" &&
!(
item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (
item.provider.api.package !== "@ai-sdk/google-vertex" &&
!(
item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
continue
const project = resolveProject(item.provider.request.body)
const location = String(resolveLocation(item.provider.request.body))
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
if (provider.api.type === "aisdk" && provider.api.url) {
provider.api.url = replaceVertexVars(provider.api.url, project, location)
}
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
provider.request.body.fetch = authFetch(provider.request.body.fetch)
}
})
}
}),
)
)
continue
const project = resolveProject(item.provider.request.body)
const location = String(resolveLocation(item.provider.request.body))
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
if (provider.api.type === "aisdk" && provider.api.url) {
provider.api.url = replaceVertexVars(provider.api.url, project, location)
}
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
provider.request.body.fetch = authFetch(provider.request.body.fetch)
}
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
@ -113,30 +111,28 @@ export const GoogleVertexPlugin = define({
})
export const GoogleVertexAnthropicPlugin = define({
id: "google-vertex-anthropic",
id: "opencode.provider.google-vertex-anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
const project =
item.provider.request.body.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
item.provider.request.body.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
const project =
item.provider.request.body.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
item.provider.request.body.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GooglePlugin = define({
id: "google",
id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GroqPlugin = define({
id: "groq",
id: "opencode.provider.groq",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,21 +1,19 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const KiloPlugin = define({
id: "kilo",
id: "opencode.provider.kilo",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
}
})
}),
})

View file

@ -1,26 +1,25 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Integration } from "../../integration"
export const LLMGatewayPlugin = define({
id: "llmgateway",
id: "opencode.provider.llmgateway",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Source"] = "opencode"
})
}
}),
)
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!configured.has(Integration.ID.make(item.provider.id))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Source"] = "opencode"
})
}
})
}),
})

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const MistralPlugin = define({
id: "mistral",
id: "opencode.provider.mistral",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,22 +1,20 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const NvidiaPlugin = define({
id: "nvidia",
id: "opencode.provider.nvidia",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
})
}),
})

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenAICompatiblePlugin = define({
id: "openai-compatible",
id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,8 +1,7 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version"
@ -32,11 +31,16 @@ type TokenResponse = {
expires_in?: number
}
type Claims = {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
}
const Claims = Schema.fromJsonString(
Schema.Struct({
chatgpt_account_id: Schema.optional(Schema.String),
organizations: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.String }))),
"https://api.openai.com/auth": Schema.optional(
Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String) }),
),
}),
)
const decodeClaims = Schema.decodeUnknownOption(Claims)
const browser = {
integrationID: Integration.ID.make("openai"),
@ -154,7 +158,7 @@ const headless = {
} satisfies IntegrationOAuthMethodRegistration
export const OpenAIPlugin = define({
id: "openai",
id: "opencode.provider.openai",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
@ -172,34 +176,32 @@ export const OpenAIPlugin = define({
draft.method.update(browser)
draft.method.update(headless)
})
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(ProviderV2.ID.openai)
if (!item) return
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
evt.model.update(item.provider.id, model.id, (draft) => {
if (!OpenAICodex.eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = []
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(ProviderV2.ID.openai)
if (!item) return
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
evt.model.update(item.provider.id, model.id, (draft) => {
if (!OpenAICodex.eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = []
})
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
@ -222,7 +224,7 @@ export const OpenAIPlugin = define({
}),
)
}),
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
} satisfies PluginInternal.InternalPlugin)
function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
@ -315,14 +317,11 @@ function extractAccountID(tokens: TokenResponse) {
function claim(token: string) {
const part = token.split(".")[1]
if (!part) return
try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return (
claims.chatgpt_account_id ??
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
claims.organizations?.[0]?.id
)
} catch {
return
}
const claims = Option.getOrUndefined(decodeClaims(Buffer.from(part, "base64url").toString()))
if (!claims) return
return (
claims.chatgpt_account_id ??
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
claims.organizations?.[0]?.id
)
}

View file

@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) {
}
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
id: "opencode",
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient

View file

@ -1,30 +1,28 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenRouterPlugin = define({
id: "openrouter",
id: "opencode.provider.openrouter",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
}
}
}),
)
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const PerplexityPlugin = define({
id: "perplexity",
id: "opencode.provider.perplexity",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,11 +1,11 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = define({
id: "sap-ai-core",
id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.sdk(
@ -19,17 +19,18 @@ export const SapAICorePlugin = define({
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
const mod = yield* Effect.promise(async () => {
return (await import(
installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
)) as Record<string, (options: any) => any>
}).pipe(Effect.orDie)
const mod: Record<string, unknown> = yield* Effect.promise(
() => import(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
const factory = mod[match]
if (typeof factory !== "function")
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
evt.sdk = mod[match](
evt.sdk = factory(
serviceKey
? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP }
: {},

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
}
export const SnowflakeCortexPlugin = define({
id: "snowflake-cortex",
id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const TogetherAIPlugin = define({
id: "togetherai",
id: "opencode.provider.togetherai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VenicePlugin = define({
id: "venice",
id: "opencode.provider.venice",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,21 +1,19 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VercelPlugin = define({
id: "vercel",
id: "opencode.provider.vercel",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["http-referer"] = "https://opencode.ai/"
provider.request.headers["x-title"] = "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["http-referer"] = "https://opencode.ai/"
provider.request.headers["x-title"] = "opencode"
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return

View file

@ -1,9 +1,9 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({
id: "xai",
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {

View file

@ -1,21 +1,19 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const ZenmuxPlugin = define({
id: "zenmux",
id: "opencode.provider.zenmux",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.request.headers["X-Title"] ??= "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.request.headers["X-Title"] ??= "opencode"
})
}
})
}),
})

View file

@ -31,7 +31,7 @@ export interface Cell {
export const makeCell = (): Cell => ({})
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
const unavailable = <A, E, R>() => Effect.die(new Error("Plugin runtime is unavailable")) as Effect.Effect<A, E, R>
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const runtime = cell.runtime

View file

@ -14,9 +14,9 @@ const defaultStore = makeStore()
/**
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginInternal` can add them on every Location boot through the ordinary
* `ctx.plugin.add` seam the same path `ConfigExternalPlugin` uses for plugins
* discovered from config. A plugin registered after a Location has booted only
* so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from
* config. A plugin registered after a Location has booted only
* applies to Locations booted afterward, matching config-plugin timing;
* embedders register at startup before creating Sessions.
*

View file

@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
@ -25,7 +25,7 @@ const REPORT_DESCRIPTION =
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
export const Plugin = define({
id: "skill",
id: "opencode.skill",
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics()
yield* ctx.skill.transform((draft) => {

View file

@ -0,0 +1,293 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event } from "@opencode-ai/schema/config"
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../config"
import { ConfigPlugin } from "../config/plugin"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginPromise } from "../plugin/promise"
import { PluginInternal } from "./internal"
import { SdkPlugins } from "./sdk"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
})
const PluginPackage = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.String),
module: Schema.optional(Schema.String),
})
type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
type Candidate =
| {
readonly type: "definition"
readonly definition: Plugin
}
| {
readonly type: "package"
readonly specifier: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
type ConfiguredPackage = {
readonly operation: Extract<Operation, { type: "add" }>
enabled: boolean
}
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin[],
post: readonly Plugin[],
operations: readonly Operation[],
) {
const plan = apply(pre, post, operations)
return yield* load(plan)
})
function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const plugins = [...pre, ...post]
const enabled = new Set(plugins.map((plugin) => plugin.id))
const packages = new Map<string, ConfiguredPackage>()
for (const operation of operations) {
if (operation.type === "remove") {
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = false
})
continue
}
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
const selectsDefinitions =
matched.length > 0 ||
operation.target === "*" ||
operation.target.endsWith(".*") ||
operation.target.startsWith("opencode.")
if (selectsDefinitions) {
matched.forEach((plugin) => enabled.add(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = true
})
continue
}
packages.set(operation.target, { operation, enabled: true })
}
const definitions: Candidate[] = pre.flatMap((definition) =>
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
)
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) =>
item.enabled
? [
{
type: "package",
specifier: item.operation.target,
options: item.operation.options,
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
},
]
: [],
)
const posts: Candidate[] = post.flatMap((definition) =>
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
)
return [...definitions, ...configured, ...posts]
}
const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) {
return yield* Effect.forEach(plan, (candidate) => {
if (candidate.type === "definition") return Effect.succeed({ plugin: candidate.definition })
return Effect.gen(function* () {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(candidate.specifier)
? pathToFileURL(candidate.specifier).href
: (yield* npm.add(candidate.specifier)).entrypoint
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
candidate.mtime === undefined
? entrypoint
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
plugin: {
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
} satisfies Plugin,
...(candidate.mtime === undefined ? {} : { version: String(candidate.mtime) }),
}
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
}).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* fs
.glob("{plugin,plugins}/*", {
cwd: directory,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(
Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })),
Effect.orElseSucceed(() => []),
)
const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), {
concurrency: "unbounded",
}).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
Effect.catch(() => Effect.succeed(undefined)),
)
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
return yield* Effect.forEach(entries, (entry) => {
if (!entry) return Effect.succeed(undefined)
const file = path.resolve(directory, entry)
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
})
export interface Interface {
readonly ready: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* PluginV2.Service
const sdk = yield* SdkPlugins.Service
const config = yield* Config.Service
const events = yield* EventV2.Service
const lock = Semaphore.makeUnsafe(1)
const reload = Effect.fn("PluginSupervisor.reload")(() =>
lock.withPermit(
Effect.gen(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre, ...sdk.all()]
// Read the current layered config before resolving plugin directives and packages.
const entries = yield* config.entries()
const operations = yield* scan(entries)
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, internal.post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
}),
),
)
yield* events.subscribe(Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
const fiber = yield* reload().pipe(
Effect.withSpan("PluginSupervisor.boot"),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ ready: Fiber.join(fiber) })
}),
)
export { layer }

View file

@ -2,10 +2,10 @@ export * as VariantPlugin from "./variant"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const Plugin = define({
id: "variant",
id: "opencode.variant",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((catalog) => {
for (const record of catalog.provider.list()) {

View file

@ -1,47 +0,0 @@
export * as Policy from "./policy"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export type Effect = typeof Effect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: Effect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
readonly hasStatements: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: EffectRuntime.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })

View file

@ -3,9 +3,11 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { ConfigVcs } from "./config/vcs"
import { Database } from "./database/database"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
@ -14,6 +16,7 @@ import { makeGlobalNode } from "./effect/app-node"
import { Hash } from "./util/hash"
import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema"
import { ProjectTable } from "./project/sql"
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
@ -27,9 +30,8 @@ export type Current = ProjectSchema.Current
export const Directory = ProjectSchema.Directory
export type Directory = ProjectSchema.Directory
export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = ProjectSchema.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
@ -56,6 +58,7 @@ export const root = Effect.fn("Project.root")(function* (
})
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
@ -72,6 +75,31 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
function fromRow(row: typeof ProjectTable.$inferSelect): Info {
const icon =
row.icon_url || row.icon_url_override || row.icon_color
? {
url: row.icon_url ?? undefined,
override: row.icon_url_override ?? undefined,
color: row.icon_color ?? undefined,
}
: undefined
return {
id: row.id,
worktree: row.worktree,
vcs: row.vcs ?? undefined,
name: row.name ?? undefined,
icon,
commands: row.commands ?? undefined,
time: {
created: row.time_created,
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@ -79,8 +107,19 @@ const layer = Layer.effect(
const git = yield* Git.Service
const global = yield* Global.Service
const proc = yield* AppProcess.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const list = Effect.fn("Project.list")(function* () {
const rows = yield* db
.select()
.from(ProjectTable)
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
return yield* projectDirectories.list(input.projectID)
})
@ -216,12 +255,12 @@ const layer = Layer.effect(
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ directories, resolve, commit })
return Service.of({ list, directories, resolve, commit })
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Global.node, AppProcess.node, ProjectDirectories.node],
deps: [Database.node, FSUtil.node, Git.node, Global.node, AppProcess.node, ProjectDirectories.node],
})

View file

@ -139,7 +139,7 @@ const layer = Layer.effect(
})
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
const resolved = AbsolutePath.make(FSUtil.resolve(input))
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
@ -202,7 +202,8 @@ const layer = Layer.effect(
const copyDirectory = yield* canonical(input.directory)
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: copyDirectory,
force: input.force,
})

View file

@ -13,6 +13,9 @@ export type Current = typeof Current.Type
export const Directory = Project.Directory
export type Directory = typeof Directory.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = Project.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type

View file

@ -1,11 +1,11 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { absoluteArrayColumn, absoluteColumn } from "../database/path"
import { Timestamps } from "../database/schema.sql"
import { ProjectSchema } from "./schema"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: DatabasePath.absoluteColumn().notNull(),
worktree: absoluteColumn().notNull(),
vcs: text(),
name: text(),
icon_url: text(),
@ -13,7 +13,7 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
sandboxes: absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
@ -24,7 +24,7 @@ export const ProjectDirectoryTable = sqliteTable(
.$type<ProjectSchema.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: DatabasePath.absoluteColumn().notNull(),
directory: absoluteColumn().notNull(),
type: text().$type<"main" | "root" | "git_worktree">(),
strategy: text(),
time_created: integer()

View file

@ -1,10 +1,10 @@
import { spawn as create } from "bun-pty"
import { spawn } from "bun-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
function spawnPty(file: string, args: string[], opts: Opts): Proc {
const pty = spawn(file, args, opts)
return {
pid: pty.pid,
onData(listener) {
@ -24,3 +24,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc {
},
}
}
export { spawnPty as spawn }

View file

@ -1,3 +1,4 @@
// ast-grep-ignore: no-star-import
import * as pty from "@lydell/node-pty"
import type { Opts, Proc } from "./pty"

View file

@ -32,7 +32,7 @@ function matches(record: Scope, input: Scope) {
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
const noLookup = () => Effect.die(new Error("PtyTicket cache must be used via set/invalidateWhen, never get"))
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>

View file

@ -35,6 +35,7 @@ const RawMatch = Schema.Struct({
),
}),
})
const decodeJsonRecord = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)
type RawMatchData = (typeof RawMatch.Type)["data"]
@ -232,10 +233,7 @@ const layer = Layer.effect(
parse: (line) =>
(Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES
? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`))
: Effect.try({
try: () => JSON.parse(line) as unknown,
catch: (cause) => failure("Invalid ripgrep JSON output", cause),
})
: decodeJsonRecord(line).pipe(Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)))
).pipe(
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")

View file

@ -43,37 +43,47 @@ export type DeepMutable<T> = T extends string | number | boolean | bigint | symb
* Nominal wrapper for scalar types. The class itself is a valid schema
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
*
* Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type`
* of a field using this newtype resolves to `Self` rather than the underlying
* branded phantom. Without that override, passing a class instance to code
* typed against `Schema.Schema.Type<FieldSchema>` would require a cast even
* though the values are structurally equivalent at runtime.
* The runtime value remains an unwrapped primitive. `Schema.brand` supplies
* the primitive schema behavior and constructor validation, while the class
* supplies the nominal TypeScript identity.
* Apply checks and annotations to the underlying schema before wrapping it;
* schema rebuild operations intentionally return the underlying schema shape.
*
* @example
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
* static make(id: string): QuestionID {
* return this.make(id)
* }
* }
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {}
*
* Schema.decodeEffect(QuestionID)(input)
* const id = QuestionID.make("question-1")
* Schema.decodeUnknownEffect(QuestionID)(input)
*/
type NewtypeSchema<Self, Tag extends string, S extends Schema.Top> = (abstract new (_: never) => {
readonly _newtype: Tag
}) &
Schema.Bottom<
Self,
S["Encoded"],
S["DecodingServices"],
S["EncodingServices"],
S["ast"],
S["Rebuild"],
S["~type.make.in"],
Self,
S["~type.parameters"],
Self,
S["~type.mutability"],
S["~type.optionality"],
S["~type.constructor.default"],
S["~encoded.mutability"],
S["~encoded.optionality"]
> &
Omit<S, keyof Schema.Top>
export function Newtype<Self>() {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S): NewtypeSchema<Self, Tag, S> => {
abstract class Base {
declare readonly _newtype: Tag
static make(value: Schema.Schema.Type<S>): Self {
return value as unknown as Self
}
}
Object.setPrototypeOf(Base, schema)
return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & {
readonly make: (value: Schema.Schema.Type<S>) => Self
} & Omit<Schema.Opaque<Self, S, {}>, "make" | "~type.make"> & {
readonly "~type.make": Self
}
Object.setPrototypeOf(Base, schema.pipe(Schema.brand(tag)))
return Base as unknown as NewtypeSchema<Self, Tag, S>
}
}

Some files were not shown because too many files have changed in this diff Show more