From b58f29a4efad95a1545ee3ae2677ca16be6993df Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 21 Aug 2026 13:48:17 -0400 Subject: [PATCH] refactor(core): simplify plugin and config boundaries (#43930) --- packages/core/src/config.ts | 20 ++++++-------------- packages/core/src/config/plugin/skill.ts | 10 +++++----- packages/core/src/plugin.ts | 5 +++-- packages/core/src/plugin/host.ts | 6 ++---- packages/core/src/plugin/runtime.ts | 3 +-- packages/core/src/skill/discovery.ts | 2 +- packages/core/src/wellknown.ts | 22 +++++++++------------- 7 files changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 86a60e10da..ad22c66c20 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -17,6 +17,7 @@ import { Event, } from "@opencode-ai/schema/config" import { Integration } from "@opencode-ai/schema/integration" +import { isRecord } from "@opencode-ai/ai/utils/record" import { Credential } from "./credential.js" import { Bus } from "./bus.js" import { Watcher } from "./filesystem/watcher.js" @@ -449,20 +450,11 @@ type Edit = { readonly path: (string | number)[]; readonly value: unknown } function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] { if (Object.is(before, after)) return [] - if ( - before !== null && - after !== null && - typeof before === "object" && - typeof after === "object" && - !Array.isArray(before) && - !Array.isArray(after) - ) { - const previous = before as Record - const next = after as Record - return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => { - if (!(key in next)) return [{ path: [...path, key], value: undefined }] - if (!(key in previous)) return [{ path: [...path, key], value: next[key] }] - return changes(previous[key], next[key], [...path, key]) + if (isRecord(before) && isRecord(after)) { + return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => { + if (!(key in after)) return [{ path: [...path, key], value: undefined }] + if (!(key in before)) return [{ path: [...path, key], value: after[key] }] + return changes(before[key], after[key], [...path, key]) }) } return [{ path, value: after }] diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index d263fbc420..2a77006a4d 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -54,7 +54,7 @@ export const Plugin = define({ "ConfigSkillPlugin.watchDirectory", )(function* (directory: string) { const target = path.resolve(directory) - const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) + const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined)) if (resolved) { yield* watch(resolved, "directory") if (resolved !== target) yield* watch(target, "file") @@ -65,7 +65,7 @@ export const Plugin = define({ if ( yield* fs.realPath(directory).pipe( Effect.as(true), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ) ) { if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`) @@ -124,11 +124,11 @@ export const Plugin = define({ for (const directory of directories) { const files = yield* fs .scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + .pipe(Effect.orElseSucceed(() => [] as string[])) for (const filepath of files.toSorted()) { - const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath))) + const resolved = yield* fs.realPath(filepath).pipe(Effect.orElseSucceed(() => filepath)) if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory") - const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined)) if (!content) continue const parsed = SkillFile.parse(directory, filepath, content) if (parsed._tag === "Skipped") { diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index b1063a42e1..82c98e059b 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,6 +2,7 @@ export * as Plugin from "./plugin.js" export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin" +import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { App } from "./app.js" import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect" @@ -31,7 +32,7 @@ export interface Interface { readonly list: () => Effect.Effect } -export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { +export type Versioned = PluginDefinition & { readonly version: string readonly source?: Plugin.Source } @@ -47,7 +48,7 @@ const layer = Layer.effect( const active = new Map() const lock = Semaphore.makeUnsafe(1) let inventory: Plugin.Info[] = [] - let host: Parameters[0] + let host: Parameters[0] const load = Effect.fnUntraced(function* (plugin: Versioned) { const child = yield* Scope.fork(scope) const inherit = yield* State.inherit() diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a8ffc72c65..17e8e9de54 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -27,12 +27,10 @@ import { Tool } from "../tool.js" import { Workspace } from "../workspace.js" import { WebSearch } from "../websearch.js" import { PluginHooks } from "./hooks.js" +import type { Interface } from "../plugin.js" const mutable = (value: T) => value as DeepMutable -export const make = Effect.fn("PluginHost.make")(function* ( - plugin: import("../plugin.js").Interface, - pluginID: string = "test", -) { +export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") { const app = yield* App.Metadata const agents = yield* Agent.Service const aisdk = yield* AISDK.Service diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index dab43abe6a..1956eb650a 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -54,11 +54,10 @@ export interface Cell { export const makeCell = (): Cell => ({}) -const unavailable = () => Effect.die(new Error("Plugin runtime is unavailable")) as Effect.Effect const require = (cell: Cell, f: (runtime: Interface) => Effect.Effect) => Effect.suspend(() => { const runtime = cell.runtime - if (runtime === undefined) return unavailable() + if (runtime === undefined) return Effect.die(new Error("Plugin runtime is unavailable")) return f(runtime) }) diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index 978c02fbe2..facfe2e8ff 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -157,7 +157,7 @@ const layer = Layer.effect( const current = version === undefined ? undefined - : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.orElseSucceed(() => undefined)) if (version === undefined || current === version) { yield* Effect.forEach(files, (file) => download(file.url, file.destination), { concurrency: fileConcurrency, diff --git a/packages/core/src/wellknown.ts b/packages/core/src/wellknown.ts index d86c6c21dc..9659883085 100644 --- a/packages/core/src/wellknown.ts +++ b/packages/core/src/wellknown.ts @@ -106,6 +106,10 @@ const layer = Layer.effect( const bus = yield* Bus.Service const cache = yield* Ref.make(new Map()) const lock = Semaphore.makeUnsafe(1) + const loadEntry = Effect.fn("WellKnown.loadEntry")(function* (origin: string) { + const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http)) + return { origin, integrationID: Integration.ID.make(origin), manifest } + }) const load = Effect.fn("WellKnown.load")(function* () { const value = yield* kv.get(sourcesKey) @@ -114,10 +118,7 @@ const layer = Layer.effect( const entries = yield* Effect.forEach(origins, (origin) => { const cached = current.get(origin) if (cached) return Effect.succeed(cached) - return inspect(origin).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })), - ) + return loadEntry(origin) }) yield* Ref.set(cache, new Map(entries.map((entry) => [entry.origin, entry]))) return entries @@ -129,12 +130,7 @@ const layer = Layer.effect( const value = yield* kv.get(sourcesKey) const origins = Schema.is(Sources)(value) ? value : [] if (!origins.length) return false - const entries = yield* Effect.forEach(origins, (origin) => - inspect(origin).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })), - ), - ) + const entries = yield* Effect.forEach(origins, loadEntry) const next = new Map(entries.map((entry) => [entry.origin, entry])) const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next) if (!changed) return false @@ -153,9 +149,9 @@ const layer = Layer.effect( return yield* lock.withPermit( Effect.gen(function* () { const origin = value.replace(/\/+$/, "") - const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http)) - if (!manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`)) - const entry = { origin, integrationID: Integration.ID.make(origin), manifest } + const entry = yield* loadEntry(origin) + if (!entry.manifest.auth) + return yield* Effect.fail(new Error(`No authentication method found at ${origin}`)) const sources = yield* kv.get(sourcesKey) const origins = Schema.is(Sources)(sources) ? sources : [] yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))