From 6b3179537dd5176ca32396221c8b72e7237f103e Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:12:36 +0200 Subject: [PATCH 1/4] fix(plugin): skip non-function exports in getLegacyPlugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External plugins fail to load because getLegacyPlugins throws TypeError when encountering any export that is not a function and doesn't have a 'server' property (e.g. constants, config objects, type re-exports). This causes the entire plugin to be silently skipped — the error is caught in applyPlugin's Effect.catch and logged, but hooks are never registered. Fix: change 'throw' to 'continue' so non-plugin exports are silently skipped, matching the behavior of getServerPlugin which already returns undefined for non-plugin values. This fixes the issue where external plugins (plugin: [...]) don't execute their module code in MiMoCode 0.38.9. --- packages/opencode/src/plugin/index.ts | 1472 ++++++++++++------------- 1 file changed, 736 insertions(+), 736 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9a4713633..fd8a36058 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,736 +1,736 @@ -import type { - Hooks, - PluginInput, - Plugin as PluginInstance, - PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, - ActorPreStopInput, - ActorPostStopInput, - ActorStopOutput, - ActorMatcher, -} from "@mimo-ai/plugin" -import { z } from "zod" -import { matchesActor } from "./matcher" -import { Config } from "../config" -import { Bus } from "../bus" -import { BusEvent } from "../bus/bus-event" -import { Log } from "../util" -import { createOpencodeClient } from "@mimo-ai/sdk" -import { Flag } from "../flag/flag" -import { CodexAuthPlugin } from "./codex" -import { XaiAuthPlugin } from "./xai" -import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" -import { Session } from "../session" -import type { SessionID } from "../session/schema" -import { NamedError } from "@mimo-ai/shared/util/error" -import { CopilotAuthPlugin } from "./github-copilot/copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" -import { PoeAuthPlugin } from "opencode-poe-auth" -import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" -import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" -import { Effect, Layer, Context, Stream } from "effect" -import { EffectBridge } from "@/effect" -import { InstanceState } from "@/effect" -import { errorMessage } from "@/util/error" -import { PluginLoader } from "./loader" -import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" -import { Glob } from "@mimo-ai/shared/util/glob" -import fs from "fs" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" - -const log = Log.create({ service: "plugin" }) - -export const HookEvent = { - Executed: BusEvent.define( - "hook.executed", - z.object({ - event: z.enum(["actor.preStop", "actor.postStop"]), - hookID: z.string(), - pluginName: z.string(), - actorID: z.string(), - agentType: z.string(), - durationMs: z.number(), - outcome: z.enum(["success", "error", "skipped"]), - continueRequested: z.boolean(), - reasonLength: z.number(), - }), - ), - ReActReentered: BusEvent.define( - "hook.react.reentered", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - iteration: z.number(), - triggeredByPlugins: z.array(z.string()), - reasonPreview: z.string(), - }), - ), - ReActMaxReached: BusEvent.define( - "hook.react.max_reached", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - }), - ), -} as const - -type HookEntry = { - hook: Hooks - pluginName: string - /** Stable per-event hook ID: `${pluginName}#${eventName}` */ - hookIDFor: (eventName: string) => string -} - -type State = { - hooks: Hooks[] - hooksWithMeta: HookEntry[] -} - -type FileHookState = { - hooks: Hooks[] - meta: HookEntry[] - dirs: string[] - /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ - files: Record - /** Mutable box: last staleness check timestamp (throttle). */ - lastCheck: { value: number } -} - -const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" -const FILE_HOOK_CHECK_INTERVAL_MS = 500 - -export type ActorStopAggregatedDecision = ActorStopOutput & { - contributingPluginNames: string[] - contributingHookIDs: string[] -} - -// Hook names that follow the (input, output) => Promise trigger pattern -type TriggerName = { - [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never -}[keyof Hooks] - -export interface Interface { - readonly trigger: < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >( - name: Name, - input: Input, - output: Output, - ) => Effect.Effect - readonly list: () => Effect.Effect - readonly init: () => Effect.Effect - readonly reloadFileHooks: () => Effect.Effect - readonly triggerActorPreStop: ( - input: ActorPreStopInput, - ) => Effect.Effect - readonly triggerActorPostStop: ( - input: ActorPostStopInput, - ) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Plugin") {} - -// Built-in plugins that are directly imported (not installed from npm) -const INTERNAL_PLUGINS: PluginInstance[] = [ - MimoAuthPlugin, - AnthropicProxyPlugin, - CodexAuthPlugin, - XaiAuthPlugin, - CopilotAuthPlugin, - // gitlab/poe auth are external npm packages typed against the published - // upstream plugin package, which carries a duplicate (nominal) copy of the - // SDK client; cast through unknown to the workspace Plugin type. - GitlabAuthPlugin as unknown as PluginInstance, - PoeAuthPlugin as unknown as PluginInstance, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - CheckpointSplitoverPlugin, - SubagentProgressCheckerPlugin, -] - -function isServerPlugin(value: unknown): value is PluginInstance { - return typeof value === "function" -} - -function getServerPlugin(value: unknown) { - if (isServerPlugin(value)) return value - if (!value || typeof value !== "object" || !("server" in value)) return - if (!isServerPlugin(value.server)) return - return value.server -} - -function getLegacyPlugins(mod: Record) { - const seen = new Set() - const result: PluginInstance[] = [] - - for (const entry of Object.values(mod)) { - if (seen.has(entry)) continue - seen.add(entry) - const plugin = getServerPlugin(entry) - if (!plugin) throw new TypeError("Plugin export is not a function") - result.push(plugin) - } - - return result -} - -async function applyPlugin( - load: PluginLoader.Loaded, - input: PluginInput, - hooks: Hooks[], - hooksWithMeta: HookEntry[], -) { - const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") - if (plugin) { - await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec - const hookObj = await (plugin as PluginModule).server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - return - } - - for (const server of getLegacyPlugins(load.mod)) { - const fnName = (server as { name?: string }).name - const pluginName = fnName && fnName !== "default" && fnName !== "" - ? fnName - : (load.pkg?.pkg ?? load.spec) - const hookObj = await server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - } -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("Plugin.state")(function* (ctx) { - const hooks: Hooks[] = [] - const hooksWithMeta: HookEntry[] = [] - const bridge = yield* EffectBridge.make() - - function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) - } - - const { Server } = yield* Effect.promise(() => import("../server/server")) - - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - directory: ctx.directory, - headers: Flag.MIMOCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), - }) - const cfg = yield* config.get() - const input: PluginInput = { - client, - project: ctx.project, - worktree: ctx.worktree, - directory: ctx.directory, - experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) - }, - }, - get serverUrl(): URL { - return Server.url ?? new URL("http://localhost:4096") - }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, - } - - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = yield* Effect.tryPromise({ - try: () => plugin(input), - catch: (err) => { - log.error("failed to load internal plugin", { name: plugin.name, error: err }) - }, - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: plugin.name, - hookIDFor: (event: string) => `${plugin.name}#${event}`, - }) - } - } - - // Load optional local extensions under src/ext/. Prefers the generated - // _manifest.ts (a fixed import specifier resolves inside Bun single-file - // executables, where filesystem scans do not); falls back to a directory - // scan for unbundled runs. Each *Plugin-named export is registered. - const extModules: Record> = {} - // @ts-ignore generated manifest; may not exist at type-check time - const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) - if (manifest._tag === "Some") { - Object.assign( - extModules, - (manifest.value as { modules?: Record> }).modules ?? {}, - ) - } else { - const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") - const extFiles = fs.existsSync(extDir) - ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") - : [] - for (const entry of extFiles) { - const mod = yield* Effect.tryPromise({ - try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), - catch: (err) => log.error("failed to import extension", { name: entry, error: err }), - }).pipe(Effect.option) - if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record - } - } - for (const [name, value] of Object.entries(extModules)) { - // Only treat *Plugin-named function exports as plugins. Other modules - // (e.g. a CLI helper export) are not plugins and must not be invoked - // as plugin factories. - const overlay = Object.entries(value).find( - ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), - )?.[1] as PluginInstance | undefined - if (!overlay) continue - log.info("loading extension", { name }) - const init = yield* Effect.tryPromise({ - try: () => overlay(input), - catch: (err) => log.error("failed to load extension", { name, error: err }), - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: name, - hookIDFor: (event: string) => `${name}#${event}`, - }) - } - } - - const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { - log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) - } - if (plugins.length) yield* config.waitForDependencies() - - const loaded = yield* Effect.promise(() => - PluginLoader.loadExternal({ - items: plugins, - kind: "server", - report: { - start(candidate) { - log.info("loading plugin", { path: candidate.plan.spec }) - }, - missing(candidate, _retry, message) { - log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) - }, - error(candidate, _retry, stage, error, resolved) { - const spec = candidate.plan.spec - const cause = error instanceof Error ? (error.cause ?? error) : error - const message = stage === "load" ? errorMessage(error) : errorMessage(cause) - - if (stage === "install") { - const parsed = parsePluginSpecifier(spec) - log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) - publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) - return - } - - if (stage === "compatibility") { - log.warn("plugin incompatible", { path: spec, error: message }) - publishPluginError(`Plugin ${spec} skipped: ${message}`) - return - } - - if (stage === "entry") { - log.error("failed to resolve plugin server entry", { path: spec, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - return - } - - log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - }, - }, - }), - ) - for (const load of loaded) { - if (!load) continue - - // Keep plugin execution sequential so hook registration and execution - // order remains deterministic across plugin runs. - yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks, hooksWithMeta), - catch: (err) => { - const message = errorMessage(err) - log.error("failed to load plugin", { path: load.spec, error: message }) - return message - }, - }).pipe( - Effect.catch(() => { - // TODO: make proper events for this - // bus.publish(Session.Event.Error, { - // error: new NamedError.Unknown({ - // message: `Failed to load plugin ${load.spec}: ${message}`, - // }).toObject(), - // }) - return Effect.void - }), - ) - } - - // Notify plugins of current config - for (const hook of hooks) { - yield* Effect.tryPromise({ - try: () => Promise.resolve((hook as any).config?.(cfg)), - catch: (err) => { - log.error("plugin config hook failed", { error: err }) - }, - }).pipe(Effect.ignore) - } - - // Subscribe to bus events, fiber interrupted when scope closes - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) - - return { hooks, hooksWithMeta } - }), - ) - - const fileHookState = yield* InstanceState.make( - Effect.fn("Plugin.fileHooks")(function* () { - const hooks: Hooks[] = [] - const meta: HookEntry[] = [] - const files: Record = {} - yield* config.get() - const dirs = yield* config.directories() - - for (const dir of dirs) { - const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) - for (const match of matches) { - const stat = yield* Effect.tryPromise({ - try: () => fs.promises.stat(match), - catch: (err) => err, - }).pipe(Effect.catch(() => Effect.succeed(undefined))) - files[match] = stat?.mtimeMs ?? 0 - // Transpile and load the hook file. We use Bun.build to produce a - // temporary .js artifact, then dynamic-import that artifact. This - // avoids two pitfalls: (1) Bun's import() ignores query-string cache - // busters so re-imports return stale modules, (2) require() transpiles - // .ts in some contexts but not others (CI Linux edge case). - const mod = yield* Effect.tryPromise({ - try: async () => { - const result = await Bun.build({ - entrypoints: [match], - target: "bun", - format: "esm", - }) - if (!result.success) throw new Error(result.logs.map(String).join("\n")) - const blob = result.outputs[0] - const tmpFile = `${match}.${Date.now()}.mjs` - await Bun.write(tmpFile, blob) - try { - return await import(tmpFile) as Record - } finally { - fs.promises.unlink(tmpFile).catch(() => {}) - } - }, - catch: (err) => err, - }).pipe(Effect.catch((err) => { - log.error("failed to load file hook", { path: match, error: errorMessage(err) }) - return Effect.succeed(undefined) - })) - if (!mod) continue - const hookObj: Hooks = (mod.default ?? mod) as Hooks - if (hookObj && typeof hookObj === "object") { - const name = path.basename(match, path.extname(match)) - hooks.push(hookObj) - meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) - log.info("loaded file hook", { path: match, name }) - } - } - } - - // Dispatch bus events to file hooks' `event` handlers. Scoped to this - // cache entry: invalidation interrupts the fiber, and the rebuild - // re-subscribes with the fresh hook set. - if (hooks.some((hook) => typeof hook.event === "function")) { - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const entry of meta) { - const fn = entry.hook.event - if (!fn) continue - try { - void Promise.resolve(fn({ event: input as any })).catch((err) => { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - }) - } catch (err) { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - } - } - }), - ), - Effect.forkScoped, - ) - } - - return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } - }), - ) - - // Staleness check: re-stat known hook files and re-glob hook dirs. Any - // mtime change, added, or removed file invalidates the cache so the next - // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other - // processes) — not just this process's write/edit tools. Throttled to - // avoid stat storms on hot trigger paths. - const freshFileHooks = Effect.gen(function* () { - const fh = yield* InstanceState.get(fileHookState) - const now = Date.now() - if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh - fh.lastCheck.value = now - - const stale = yield* Effect.promise(async () => { - const known = Object.keys(fh.files) - const seen = new Set() - for (const dir of fh.dirs) { - for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { - seen.add(match) - if (!(match in fh.files)) return true - } - } - for (const file of known) { - if (!seen.has(file)) return true - const stat = await fs.promises.stat(file).catch(() => undefined) - if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true - } - return false - }) - - if (!stale) return fh - log.info("file hooks changed on disk, reloading") - yield* InstanceState.invalidate(fileHookState) - return yield* InstanceState.get(fileHookState) - }) - - const aggregateDecision = ( - input: ActorPreStopInput | ActorPostStopInput, - eventName: "actor.preStop" | "actor.postStop", - ) => - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - const reasons: string[] = [] - const pluginNames: string[] = [] - const hookIDs: string[] = [] - let anyContinue = false - - for (const entry of [...s.hooksWithMeta, ...fh.meta]) { - const reg = entry.hook[eventName] - if (!reg) continue - - const fn = typeof reg === "function" ? reg : reg.run - const matcher: ActorMatcher | undefined = - typeof reg === "function" ? undefined : reg.matcher - - if (!matchesActor(matcher, input)) { - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs: 0, - outcome: "skipped", - continueRequested: false, - reasonLength: 0, - }) - continue - } - - const startedAt = Date.now() - const o: ActorStopOutput = { continue: false } - let hookOutcome: "success" | "error" = "success" - // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative - // cancellation into their fetch / DB calls. Effect interrupt only stops - // the awaiting fiber — the underlying Promise keeps running and may - // bus.publish events after the actor has been cleaned up. See spec - // Future work for full discussion. Strict in-process cancellation - // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. - yield* Effect.tryPromise({ - try: () => fn(input as never, o), - catch: (err) => err, - }).pipe( - Effect.tapError((err) => - Effect.gen(function* () { - hookOutcome = "error" - log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID as SessionID, - error: new NamedError.Unknown({ - message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, - }).toObject(), - }) - }), - ), - Effect.ignore, - ) - - const durationMs = Date.now() - startedAt - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs, - outcome: hookOutcome, - continueRequested: o.continue === true, - reasonLength: o.reason?.length ?? 0, - }) - - if (o.continue === true && o.reason && o.reason.length > 0) { - anyContinue = true - reasons.push(o.reason) - pluginNames.push(entry.pluginName) - hookIDs.push(entry.hookIDFor(eventName)) - } else if (o.continue === true) { - log.warn(`${eventName} hook returned continue=true without reason; ignored`, { - pluginName: entry.pluginName, - }) - } - } - - const aggregated: ActorStopAggregatedDecision = { - continue: anyContinue, - reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, - contributingPluginNames: pluginNames, - contributingHookIDs: hookIDs, - } - return aggregated - }) - - const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( - input: ActorPreStopInput, - ) { - return yield* aggregateDecision(input, "actor.preStop") - }) - - const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( - input: ActorPostStopInput, - ) { - return yield* aggregateDecision(input, "actor.postStop") - }) - - const HOOK_TIMEOUT_MS = 5000 - const CIRCUIT_BREAKER_THRESHOLD = 3 - const hookFailures = new Map() - - const trigger = Effect.fn("Plugin.trigger")(function* < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >(name: Name, input: Input, output: Output) { - if (!name) return output - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - - for (const entry of s.hooksWithMeta) { - const fn = entry.hook[name] as any - if (!fn) continue - yield* Effect.promise(async () => fn(input, output)) - } - - for (const entry of fh.meta) { - const fn = entry.hook[name] as any - if (!fn) continue - const hookID = entry.hookIDFor(name) - - if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { - log.warn("hook circuit-breaker open, skipping", { hook: hookID }) - continue - } - - const snapshot = structuredClone(output) - const failed = yield* Effect.tryPromise({ - try: async () => { - await Promise.race([ - Promise.resolve(fn(input, output)), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), - ), - ]) - }, - catch: (err) => err, - }).pipe( - Effect.map(() => false), - Effect.catch((err) => { - Object.assign(output as any, snapshot) - const count = (hookFailures.get(hookID) ?? 0) + 1 - hookFailures.set(hookID, count) - log.error("file hook failed, output rolled back", { - hook: hookID, - event: name, - error: errorMessage(err), - consecutiveFailures: count, - circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, - }) - return Effect.succeed(true) - }), - ) - if (!failed) hookFailures.delete(hookID) - } - return output - }) - - const list = Effect.fn("Plugin.list")(function* () { - const s = yield* InstanceState.get(state) - return s.hooks - }) - - const init = Effect.fn("Plugin.init")(function* () { - yield* InstanceState.get(state) - yield* InstanceState.get(fileHookState) - }) - - const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { - yield* InstanceState.invalidate(fileHookState) - }) - - return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) - -export * as Plugin from "." +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, + ActorPreStopInput, + ActorPostStopInput, + ActorStopOutput, + ActorMatcher, +} from "@mimo-ai/plugin" +import { z } from "zod" +import { matchesActor } from "./matcher" +import { Config } from "../config" +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Log } from "../util" +import { createOpencodeClient } from "@mimo-ai/sdk" +import { Flag } from "../flag/flag" +import { CodexAuthPlugin } from "./codex" +import { XaiAuthPlugin } from "./xai" +import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" +import { Session } from "../session" +import type { SessionID } from "../session/schema" +import { NamedError } from "@mimo-ai/shared/util/error" +import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" +import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" +import { Effect, Layer, Context, Stream } from "effect" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" +import { errorMessage } from "@/util/error" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" +import { Glob } from "@mimo-ai/shared/util/glob" +import fs from "fs" +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" + +const log = Log.create({ service: "plugin" }) + +export const HookEvent = { + Executed: BusEvent.define( + "hook.executed", + z.object({ + event: z.enum(["actor.preStop", "actor.postStop"]), + hookID: z.string(), + pluginName: z.string(), + actorID: z.string(), + agentType: z.string(), + durationMs: z.number(), + outcome: z.enum(["success", "error", "skipped"]), + continueRequested: z.boolean(), + reasonLength: z.number(), + }), + ), + ReActReentered: BusEvent.define( + "hook.react.reentered", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + iteration: z.number(), + triggeredByPlugins: z.array(z.string()), + reasonPreview: z.string(), + }), + ), + ReActMaxReached: BusEvent.define( + "hook.react.max_reached", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + }), + ), +} as const + +type HookEntry = { + hook: Hooks + pluginName: string + /** Stable per-event hook ID: `${pluginName}#${eventName}` */ + hookIDFor: (eventName: string) => string +} + +type State = { + hooks: Hooks[] + hooksWithMeta: HookEntry[] +} + +type FileHookState = { + hooks: Hooks[] + meta: HookEntry[] + dirs: string[] + /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ + files: Record + /** Mutable box: last staleness check timestamp (throttle). */ + lastCheck: { value: number } +} + +const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" +const FILE_HOOK_CHECK_INTERVAL_MS = 500 + +export type ActorStopAggregatedDecision = ActorStopOutput & { + contributingPluginNames: string[] + contributingHookIDs: string[] +} + +// Hook names that follow the (input, output) => Promise trigger pattern +type TriggerName = { + [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never +}[keyof Hooks] + +export interface Interface { + readonly trigger: < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect + readonly reloadFileHooks: () => Effect.Effect + readonly triggerActorPreStop: ( + input: ActorPreStopInput, + ) => Effect.Effect + readonly triggerActorPostStop: ( + input: ActorPostStopInput, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Plugin") {} + +// Built-in plugins that are directly imported (not installed from npm) +const INTERNAL_PLUGINS: PluginInstance[] = [ + MimoAuthPlugin, + AnthropicProxyPlugin, + CodexAuthPlugin, + XaiAuthPlugin, + CopilotAuthPlugin, + // gitlab/poe auth are external npm packages typed against the published + // upstream plugin package, which carries a duplicate (nominal) copy of the + // SDK client; cast through unknown to the workspace Plugin type. + GitlabAuthPlugin as unknown as PluginInstance, + PoeAuthPlugin as unknown as PluginInstance, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + CheckpointSplitoverPlugin, + SubagentProgressCheckerPlugin, +] + +function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server +} + +function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) continue + result.push(plugin) + } + + return result +} + +async function applyPlugin( + load: PluginLoader.Loaded, + input: PluginInput, + hooks: Hooks[], + hooksWithMeta: HookEntry[], +) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + if (plugin) { + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec + const hookObj = await (plugin as PluginModule).server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + return + } + + for (const server of getLegacyPlugins(load.mod)) { + const fnName = (server as { name?: string }).name + const pluginName = fnName && fnName !== "default" && fnName !== "" + ? fnName + : (load.pkg?.pkg ?? load.spec) + const hookObj = await server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("Plugin.state")(function* (ctx) { + const hooks: Hooks[] = [] + const hooksWithMeta: HookEntry[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } + + const { Server } = yield* Effect.promise(() => import("../server/server")) + + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: ctx.directory, + headers: Flag.MIMOCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, + } + : undefined, + fetch: async (...args) => (await Server.Default()).app.fetch(...args), + }) + const cfg = yield* config.get() + const input: PluginInput = { + client, + project: ctx.project, + worktree: ctx.worktree, + directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, + } + + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = yield* Effect.tryPromise({ + try: () => plugin(input), + catch: (err) => { + log.error("failed to load internal plugin", { name: plugin.name, error: err }) + }, + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: plugin.name, + hookIDFor: (event: string) => `${plugin.name}#${event}`, + }) + } + } + + // Load optional local extensions under src/ext/. Prefers the generated + // _manifest.ts (a fixed import specifier resolves inside Bun single-file + // executables, where filesystem scans do not); falls back to a directory + // scan for unbundled runs. Each *Plugin-named export is registered. + const extModules: Record> = {} + // @ts-ignore generated manifest; may not exist at type-check time + const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) + if (manifest._tag === "Some") { + Object.assign( + extModules, + (manifest.value as { modules?: Record> }).modules ?? {}, + ) + } else { + const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") + const extFiles = fs.existsSync(extDir) + ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") + : [] + for (const entry of extFiles) { + const mod = yield* Effect.tryPromise({ + try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), + catch: (err) => log.error("failed to import extension", { name: entry, error: err }), + }).pipe(Effect.option) + if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record + } + } + for (const [name, value] of Object.entries(extModules)) { + // Only treat *Plugin-named function exports as plugins. Other modules + // (e.g. a CLI helper export) are not plugins and must not be invoked + // as plugin factories. + const overlay = Object.entries(value).find( + ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), + )?.[1] as PluginInstance | undefined + if (!overlay) continue + log.info("loading extension", { name }) + const init = yield* Effect.tryPromise({ + try: () => overlay(input), + catch: (err) => log.error("failed to load extension", { name, error: err }), + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: name, + hookIDFor: (event: string) => `${name}#${event}`, + }) + } + } + + const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) + if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) + } + if (plugins.length) yield* config.waitForDependencies() + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) { + log.info("loading plugin", { path: candidate.plan.spec }) + }, + missing(candidate, _retry, message) { + log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) + }, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) + + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + log.warn("plugin incompatible", { path: spec, error: message }) + publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + log.error("failed to resolve plugin server entry", { path: spec, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks, hooksWithMeta), + catch: (err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: load.spec, error: message }) + return message + }, + }).pipe( + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), + ) + } + + // Notify plugins of current config + for (const hook of hooks) { + yield* Effect.tryPromise({ + try: () => Promise.resolve((hook as any).config?.(cfg)), + catch: (err) => { + log.error("plugin config hook failed", { error: err }) + }, + }).pipe(Effect.ignore) + } + + // Subscribe to bus events, fiber interrupted when scope closes + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: input as any }) + } + }), + ), + Effect.forkScoped, + ) + + return { hooks, hooksWithMeta } + }), + ) + + const fileHookState = yield* InstanceState.make( + Effect.fn("Plugin.fileHooks")(function* () { + const hooks: Hooks[] = [] + const meta: HookEntry[] = [] + const files: Record = {} + yield* config.get() + const dirs = yield* config.directories() + + for (const dir of dirs) { + const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) + for (const match of matches) { + const stat = yield* Effect.tryPromise({ + try: () => fs.promises.stat(match), + catch: (err) => err, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + files[match] = stat?.mtimeMs ?? 0 + // Transpile and load the hook file. We use Bun.build to produce a + // temporary .js artifact, then dynamic-import that artifact. This + // avoids two pitfalls: (1) Bun's import() ignores query-string cache + // busters so re-imports return stale modules, (2) require() transpiles + // .ts in some contexts but not others (CI Linux edge case). + const mod = yield* Effect.tryPromise({ + try: async () => { + const result = await Bun.build({ + entrypoints: [match], + target: "bun", + format: "esm", + }) + if (!result.success) throw new Error(result.logs.map(String).join("\n")) + const blob = result.outputs[0] + const tmpFile = `${match}.${Date.now()}.mjs` + await Bun.write(tmpFile, blob) + try { + return await import(tmpFile) as Record + } finally { + fs.promises.unlink(tmpFile).catch(() => {}) + } + }, + catch: (err) => err, + }).pipe(Effect.catch((err) => { + log.error("failed to load file hook", { path: match, error: errorMessage(err) }) + return Effect.succeed(undefined) + })) + if (!mod) continue + const hookObj: Hooks = (mod.default ?? mod) as Hooks + if (hookObj && typeof hookObj === "object") { + const name = path.basename(match, path.extname(match)) + hooks.push(hookObj) + meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) + log.info("loaded file hook", { path: match, name }) + } + } + } + + // Dispatch bus events to file hooks' `event` handlers. Scoped to this + // cache entry: invalidation interrupts the fiber, and the rebuild + // re-subscribes with the fresh hook set. + if (hooks.some((hook) => typeof hook.event === "function")) { + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const entry of meta) { + const fn = entry.hook.event + if (!fn) continue + try { + void Promise.resolve(fn({ event: input as any })).catch((err) => { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + }) + } catch (err) { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + } + } + }), + ), + Effect.forkScoped, + ) + } + + return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } + }), + ) + + // Staleness check: re-stat known hook files and re-glob hook dirs. Any + // mtime change, added, or removed file invalidates the cache so the next + // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other + // processes) — not just this process's write/edit tools. Throttled to + // avoid stat storms on hot trigger paths. + const freshFileHooks = Effect.gen(function* () { + const fh = yield* InstanceState.get(fileHookState) + const now = Date.now() + if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh + fh.lastCheck.value = now + + const stale = yield* Effect.promise(async () => { + const known = Object.keys(fh.files) + const seen = new Set() + for (const dir of fh.dirs) { + for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { + seen.add(match) + if (!(match in fh.files)) return true + } + } + for (const file of known) { + if (!seen.has(file)) return true + const stat = await fs.promises.stat(file).catch(() => undefined) + if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true + } + return false + }) + + if (!stale) return fh + log.info("file hooks changed on disk, reloading") + yield* InstanceState.invalidate(fileHookState) + return yield* InstanceState.get(fileHookState) + }) + + const aggregateDecision = ( + input: ActorPreStopInput | ActorPostStopInput, + eventName: "actor.preStop" | "actor.postStop", + ) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + const reasons: string[] = [] + const pluginNames: string[] = [] + const hookIDs: string[] = [] + let anyContinue = false + + for (const entry of [...s.hooksWithMeta, ...fh.meta]) { + const reg = entry.hook[eventName] + if (!reg) continue + + const fn = typeof reg === "function" ? reg : reg.run + const matcher: ActorMatcher | undefined = + typeof reg === "function" ? undefined : reg.matcher + + if (!matchesActor(matcher, input)) { + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs: 0, + outcome: "skipped", + continueRequested: false, + reasonLength: 0, + }) + continue + } + + const startedAt = Date.now() + const o: ActorStopOutput = { continue: false } + let hookOutcome: "success" | "error" = "success" + // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative + // cancellation into their fetch / DB calls. Effect interrupt only stops + // the awaiting fiber — the underlying Promise keeps running and may + // bus.publish events after the actor has been cleaned up. See spec + // Future work for full discussion. Strict in-process cancellation + // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. + yield* Effect.tryPromise({ + try: () => fn(input as never, o), + catch: (err) => err, + }).pipe( + Effect.tapError((err) => + Effect.gen(function* () { + hookOutcome = "error" + log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID as SessionID, + error: new NamedError.Unknown({ + message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, + }).toObject(), + }) + }), + ), + Effect.ignore, + ) + + const durationMs = Date.now() - startedAt + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs, + outcome: hookOutcome, + continueRequested: o.continue === true, + reasonLength: o.reason?.length ?? 0, + }) + + if (o.continue === true && o.reason && o.reason.length > 0) { + anyContinue = true + reasons.push(o.reason) + pluginNames.push(entry.pluginName) + hookIDs.push(entry.hookIDFor(eventName)) + } else if (o.continue === true) { + log.warn(`${eventName} hook returned continue=true without reason; ignored`, { + pluginName: entry.pluginName, + }) + } + } + + const aggregated: ActorStopAggregatedDecision = { + continue: anyContinue, + reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, + contributingPluginNames: pluginNames, + contributingHookIDs: hookIDs, + } + return aggregated + }) + + const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( + input: ActorPreStopInput, + ) { + return yield* aggregateDecision(input, "actor.preStop") + }) + + const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( + input: ActorPostStopInput, + ) { + return yield* aggregateDecision(input, "actor.postStop") + }) + + const HOOK_TIMEOUT_MS = 5000 + const CIRCUIT_BREAKER_THRESHOLD = 3 + const hookFailures = new Map() + + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + + for (const entry of s.hooksWithMeta) { + const fn = entry.hook[name] as any + if (!fn) continue + yield* Effect.promise(async () => fn(input, output)) + } + + for (const entry of fh.meta) { + const fn = entry.hook[name] as any + if (!fn) continue + const hookID = entry.hookIDFor(name) + + if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { + log.warn("hook circuit-breaker open, skipping", { hook: hookID }) + continue + } + + const snapshot = structuredClone(output) + const failed = yield* Effect.tryPromise({ + try: async () => { + await Promise.race([ + Promise.resolve(fn(input, output)), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), + ), + ]) + }, + catch: (err) => err, + }).pipe( + Effect.map(() => false), + Effect.catch((err) => { + Object.assign(output as any, snapshot) + const count = (hookFailures.get(hookID) ?? 0) + 1 + hookFailures.set(hookID, count) + log.error("file hook failed, output rolled back", { + hook: hookID, + event: name, + error: errorMessage(err), + consecutiveFailures: count, + circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, + }) + return Effect.succeed(true) + }), + ) + if (!failed) hookFailures.delete(hookID) + } + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* InstanceState.get(state) + yield* InstanceState.get(fileHookState) + }) + + const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { + yield* InstanceState.invalidate(fileHookState) + }) + + return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + +export * as Plugin from "." From 0aca2747fa618b68a4346fb6bc6ef544953aae2b Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:35:36 +0200 Subject: [PATCH 2/4] fix(plugin): prettier formatting --- packages/opencode/src/plugin/index.ts | 1466 ++++++++++++------------- 1 file changed, 730 insertions(+), 736 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fd8a36058..0113763b3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,736 +1,730 @@ -import type { - Hooks, - PluginInput, - Plugin as PluginInstance, - PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, - ActorPreStopInput, - ActorPostStopInput, - ActorStopOutput, - ActorMatcher, -} from "@mimo-ai/plugin" -import { z } from "zod" -import { matchesActor } from "./matcher" -import { Config } from "../config" -import { Bus } from "../bus" -import { BusEvent } from "../bus/bus-event" -import { Log } from "../util" -import { createOpencodeClient } from "@mimo-ai/sdk" -import { Flag } from "../flag/flag" -import { CodexAuthPlugin } from "./codex" -import { XaiAuthPlugin } from "./xai" -import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" -import { Session } from "../session" -import type { SessionID } from "../session/schema" -import { NamedError } from "@mimo-ai/shared/util/error" -import { CopilotAuthPlugin } from "./github-copilot/copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" -import { PoeAuthPlugin } from "opencode-poe-auth" -import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" -import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" -import { Effect, Layer, Context, Stream } from "effect" -import { EffectBridge } from "@/effect" -import { InstanceState } from "@/effect" -import { errorMessage } from "@/util/error" -import { PluginLoader } from "./loader" -import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" -import { Glob } from "@mimo-ai/shared/util/glob" -import fs from "fs" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" - -const log = Log.create({ service: "plugin" }) - -export const HookEvent = { - Executed: BusEvent.define( - "hook.executed", - z.object({ - event: z.enum(["actor.preStop", "actor.postStop"]), - hookID: z.string(), - pluginName: z.string(), - actorID: z.string(), - agentType: z.string(), - durationMs: z.number(), - outcome: z.enum(["success", "error", "skipped"]), - continueRequested: z.boolean(), - reasonLength: z.number(), - }), - ), - ReActReentered: BusEvent.define( - "hook.react.reentered", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - iteration: z.number(), - triggeredByPlugins: z.array(z.string()), - reasonPreview: z.string(), - }), - ), - ReActMaxReached: BusEvent.define( - "hook.react.max_reached", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - }), - ), -} as const - -type HookEntry = { - hook: Hooks - pluginName: string - /** Stable per-event hook ID: `${pluginName}#${eventName}` */ - hookIDFor: (eventName: string) => string -} - -type State = { - hooks: Hooks[] - hooksWithMeta: HookEntry[] -} - -type FileHookState = { - hooks: Hooks[] - meta: HookEntry[] - dirs: string[] - /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ - files: Record - /** Mutable box: last staleness check timestamp (throttle). */ - lastCheck: { value: number } -} - -const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" -const FILE_HOOK_CHECK_INTERVAL_MS = 500 - -export type ActorStopAggregatedDecision = ActorStopOutput & { - contributingPluginNames: string[] - contributingHookIDs: string[] -} - -// Hook names that follow the (input, output) => Promise trigger pattern -type TriggerName = { - [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never -}[keyof Hooks] - -export interface Interface { - readonly trigger: < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >( - name: Name, - input: Input, - output: Output, - ) => Effect.Effect - readonly list: () => Effect.Effect - readonly init: () => Effect.Effect - readonly reloadFileHooks: () => Effect.Effect - readonly triggerActorPreStop: ( - input: ActorPreStopInput, - ) => Effect.Effect - readonly triggerActorPostStop: ( - input: ActorPostStopInput, - ) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Plugin") {} - -// Built-in plugins that are directly imported (not installed from npm) -const INTERNAL_PLUGINS: PluginInstance[] = [ - MimoAuthPlugin, - AnthropicProxyPlugin, - CodexAuthPlugin, - XaiAuthPlugin, - CopilotAuthPlugin, - // gitlab/poe auth are external npm packages typed against the published - // upstream plugin package, which carries a duplicate (nominal) copy of the - // SDK client; cast through unknown to the workspace Plugin type. - GitlabAuthPlugin as unknown as PluginInstance, - PoeAuthPlugin as unknown as PluginInstance, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - CheckpointSplitoverPlugin, - SubagentProgressCheckerPlugin, -] - -function isServerPlugin(value: unknown): value is PluginInstance { - return typeof value === "function" -} - -function getServerPlugin(value: unknown) { - if (isServerPlugin(value)) return value - if (!value || typeof value !== "object" || !("server" in value)) return - if (!isServerPlugin(value.server)) return - return value.server -} - -function getLegacyPlugins(mod: Record) { - const seen = new Set() - const result: PluginInstance[] = [] - - for (const entry of Object.values(mod)) { - if (seen.has(entry)) continue - seen.add(entry) - const plugin = getServerPlugin(entry) - if (!plugin) continue - result.push(plugin) - } - - return result -} - -async function applyPlugin( - load: PluginLoader.Loaded, - input: PluginInput, - hooks: Hooks[], - hooksWithMeta: HookEntry[], -) { - const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") - if (plugin) { - await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec - const hookObj = await (plugin as PluginModule).server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - return - } - - for (const server of getLegacyPlugins(load.mod)) { - const fnName = (server as { name?: string }).name - const pluginName = fnName && fnName !== "default" && fnName !== "" - ? fnName - : (load.pkg?.pkg ?? load.spec) - const hookObj = await server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - } -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("Plugin.state")(function* (ctx) { - const hooks: Hooks[] = [] - const hooksWithMeta: HookEntry[] = [] - const bridge = yield* EffectBridge.make() - - function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) - } - - const { Server } = yield* Effect.promise(() => import("../server/server")) - - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - directory: ctx.directory, - headers: Flag.MIMOCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), - }) - const cfg = yield* config.get() - const input: PluginInput = { - client, - project: ctx.project, - worktree: ctx.worktree, - directory: ctx.directory, - experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) - }, - }, - get serverUrl(): URL { - return Server.url ?? new URL("http://localhost:4096") - }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, - } - - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = yield* Effect.tryPromise({ - try: () => plugin(input), - catch: (err) => { - log.error("failed to load internal plugin", { name: plugin.name, error: err }) - }, - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: plugin.name, - hookIDFor: (event: string) => `${plugin.name}#${event}`, - }) - } - } - - // Load optional local extensions under src/ext/. Prefers the generated - // _manifest.ts (a fixed import specifier resolves inside Bun single-file - // executables, where filesystem scans do not); falls back to a directory - // scan for unbundled runs. Each *Plugin-named export is registered. - const extModules: Record> = {} - // @ts-ignore generated manifest; may not exist at type-check time - const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) - if (manifest._tag === "Some") { - Object.assign( - extModules, - (manifest.value as { modules?: Record> }).modules ?? {}, - ) - } else { - const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") - const extFiles = fs.existsSync(extDir) - ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") - : [] - for (const entry of extFiles) { - const mod = yield* Effect.tryPromise({ - try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), - catch: (err) => log.error("failed to import extension", { name: entry, error: err }), - }).pipe(Effect.option) - if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record - } - } - for (const [name, value] of Object.entries(extModules)) { - // Only treat *Plugin-named function exports as plugins. Other modules - // (e.g. a CLI helper export) are not plugins and must not be invoked - // as plugin factories. - const overlay = Object.entries(value).find( - ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), - )?.[1] as PluginInstance | undefined - if (!overlay) continue - log.info("loading extension", { name }) - const init = yield* Effect.tryPromise({ - try: () => overlay(input), - catch: (err) => log.error("failed to load extension", { name, error: err }), - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: name, - hookIDFor: (event: string) => `${name}#${event}`, - }) - } - } - - const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { - log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) - } - if (plugins.length) yield* config.waitForDependencies() - - const loaded = yield* Effect.promise(() => - PluginLoader.loadExternal({ - items: plugins, - kind: "server", - report: { - start(candidate) { - log.info("loading plugin", { path: candidate.plan.spec }) - }, - missing(candidate, _retry, message) { - log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) - }, - error(candidate, _retry, stage, error, resolved) { - const spec = candidate.plan.spec - const cause = error instanceof Error ? (error.cause ?? error) : error - const message = stage === "load" ? errorMessage(error) : errorMessage(cause) - - if (stage === "install") { - const parsed = parsePluginSpecifier(spec) - log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) - publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) - return - } - - if (stage === "compatibility") { - log.warn("plugin incompatible", { path: spec, error: message }) - publishPluginError(`Plugin ${spec} skipped: ${message}`) - return - } - - if (stage === "entry") { - log.error("failed to resolve plugin server entry", { path: spec, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - return - } - - log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - }, - }, - }), - ) - for (const load of loaded) { - if (!load) continue - - // Keep plugin execution sequential so hook registration and execution - // order remains deterministic across plugin runs. - yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks, hooksWithMeta), - catch: (err) => { - const message = errorMessage(err) - log.error("failed to load plugin", { path: load.spec, error: message }) - return message - }, - }).pipe( - Effect.catch(() => { - // TODO: make proper events for this - // bus.publish(Session.Event.Error, { - // error: new NamedError.Unknown({ - // message: `Failed to load plugin ${load.spec}: ${message}`, - // }).toObject(), - // }) - return Effect.void - }), - ) - } - - // Notify plugins of current config - for (const hook of hooks) { - yield* Effect.tryPromise({ - try: () => Promise.resolve((hook as any).config?.(cfg)), - catch: (err) => { - log.error("plugin config hook failed", { error: err }) - }, - }).pipe(Effect.ignore) - } - - // Subscribe to bus events, fiber interrupted when scope closes - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) - - return { hooks, hooksWithMeta } - }), - ) - - const fileHookState = yield* InstanceState.make( - Effect.fn("Plugin.fileHooks")(function* () { - const hooks: Hooks[] = [] - const meta: HookEntry[] = [] - const files: Record = {} - yield* config.get() - const dirs = yield* config.directories() - - for (const dir of dirs) { - const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) - for (const match of matches) { - const stat = yield* Effect.tryPromise({ - try: () => fs.promises.stat(match), - catch: (err) => err, - }).pipe(Effect.catch(() => Effect.succeed(undefined))) - files[match] = stat?.mtimeMs ?? 0 - // Transpile and load the hook file. We use Bun.build to produce a - // temporary .js artifact, then dynamic-import that artifact. This - // avoids two pitfalls: (1) Bun's import() ignores query-string cache - // busters so re-imports return stale modules, (2) require() transpiles - // .ts in some contexts but not others (CI Linux edge case). - const mod = yield* Effect.tryPromise({ - try: async () => { - const result = await Bun.build({ - entrypoints: [match], - target: "bun", - format: "esm", - }) - if (!result.success) throw new Error(result.logs.map(String).join("\n")) - const blob = result.outputs[0] - const tmpFile = `${match}.${Date.now()}.mjs` - await Bun.write(tmpFile, blob) - try { - return await import(tmpFile) as Record - } finally { - fs.promises.unlink(tmpFile).catch(() => {}) - } - }, - catch: (err) => err, - }).pipe(Effect.catch((err) => { - log.error("failed to load file hook", { path: match, error: errorMessage(err) }) - return Effect.succeed(undefined) - })) - if (!mod) continue - const hookObj: Hooks = (mod.default ?? mod) as Hooks - if (hookObj && typeof hookObj === "object") { - const name = path.basename(match, path.extname(match)) - hooks.push(hookObj) - meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) - log.info("loaded file hook", { path: match, name }) - } - } - } - - // Dispatch bus events to file hooks' `event` handlers. Scoped to this - // cache entry: invalidation interrupts the fiber, and the rebuild - // re-subscribes with the fresh hook set. - if (hooks.some((hook) => typeof hook.event === "function")) { - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const entry of meta) { - const fn = entry.hook.event - if (!fn) continue - try { - void Promise.resolve(fn({ event: input as any })).catch((err) => { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - }) - } catch (err) { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - } - } - }), - ), - Effect.forkScoped, - ) - } - - return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } - }), - ) - - // Staleness check: re-stat known hook files and re-glob hook dirs. Any - // mtime change, added, or removed file invalidates the cache so the next - // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other - // processes) — not just this process's write/edit tools. Throttled to - // avoid stat storms on hot trigger paths. - const freshFileHooks = Effect.gen(function* () { - const fh = yield* InstanceState.get(fileHookState) - const now = Date.now() - if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh - fh.lastCheck.value = now - - const stale = yield* Effect.promise(async () => { - const known = Object.keys(fh.files) - const seen = new Set() - for (const dir of fh.dirs) { - for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { - seen.add(match) - if (!(match in fh.files)) return true - } - } - for (const file of known) { - if (!seen.has(file)) return true - const stat = await fs.promises.stat(file).catch(() => undefined) - if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true - } - return false - }) - - if (!stale) return fh - log.info("file hooks changed on disk, reloading") - yield* InstanceState.invalidate(fileHookState) - return yield* InstanceState.get(fileHookState) - }) - - const aggregateDecision = ( - input: ActorPreStopInput | ActorPostStopInput, - eventName: "actor.preStop" | "actor.postStop", - ) => - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - const reasons: string[] = [] - const pluginNames: string[] = [] - const hookIDs: string[] = [] - let anyContinue = false - - for (const entry of [...s.hooksWithMeta, ...fh.meta]) { - const reg = entry.hook[eventName] - if (!reg) continue - - const fn = typeof reg === "function" ? reg : reg.run - const matcher: ActorMatcher | undefined = - typeof reg === "function" ? undefined : reg.matcher - - if (!matchesActor(matcher, input)) { - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs: 0, - outcome: "skipped", - continueRequested: false, - reasonLength: 0, - }) - continue - } - - const startedAt = Date.now() - const o: ActorStopOutput = { continue: false } - let hookOutcome: "success" | "error" = "success" - // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative - // cancellation into their fetch / DB calls. Effect interrupt only stops - // the awaiting fiber — the underlying Promise keeps running and may - // bus.publish events after the actor has been cleaned up. See spec - // Future work for full discussion. Strict in-process cancellation - // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. - yield* Effect.tryPromise({ - try: () => fn(input as never, o), - catch: (err) => err, - }).pipe( - Effect.tapError((err) => - Effect.gen(function* () { - hookOutcome = "error" - log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID as SessionID, - error: new NamedError.Unknown({ - message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, - }).toObject(), - }) - }), - ), - Effect.ignore, - ) - - const durationMs = Date.now() - startedAt - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs, - outcome: hookOutcome, - continueRequested: o.continue === true, - reasonLength: o.reason?.length ?? 0, - }) - - if (o.continue === true && o.reason && o.reason.length > 0) { - anyContinue = true - reasons.push(o.reason) - pluginNames.push(entry.pluginName) - hookIDs.push(entry.hookIDFor(eventName)) - } else if (o.continue === true) { - log.warn(`${eventName} hook returned continue=true without reason; ignored`, { - pluginName: entry.pluginName, - }) - } - } - - const aggregated: ActorStopAggregatedDecision = { - continue: anyContinue, - reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, - contributingPluginNames: pluginNames, - contributingHookIDs: hookIDs, - } - return aggregated - }) - - const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( - input: ActorPreStopInput, - ) { - return yield* aggregateDecision(input, "actor.preStop") - }) - - const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( - input: ActorPostStopInput, - ) { - return yield* aggregateDecision(input, "actor.postStop") - }) - - const HOOK_TIMEOUT_MS = 5000 - const CIRCUIT_BREAKER_THRESHOLD = 3 - const hookFailures = new Map() - - const trigger = Effect.fn("Plugin.trigger")(function* < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >(name: Name, input: Input, output: Output) { - if (!name) return output - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - - for (const entry of s.hooksWithMeta) { - const fn = entry.hook[name] as any - if (!fn) continue - yield* Effect.promise(async () => fn(input, output)) - } - - for (const entry of fh.meta) { - const fn = entry.hook[name] as any - if (!fn) continue - const hookID = entry.hookIDFor(name) - - if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { - log.warn("hook circuit-breaker open, skipping", { hook: hookID }) - continue - } - - const snapshot = structuredClone(output) - const failed = yield* Effect.tryPromise({ - try: async () => { - await Promise.race([ - Promise.resolve(fn(input, output)), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), - ), - ]) - }, - catch: (err) => err, - }).pipe( - Effect.map(() => false), - Effect.catch((err) => { - Object.assign(output as any, snapshot) - const count = (hookFailures.get(hookID) ?? 0) + 1 - hookFailures.set(hookID, count) - log.error("file hook failed, output rolled back", { - hook: hookID, - event: name, - error: errorMessage(err), - consecutiveFailures: count, - circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, - }) - return Effect.succeed(true) - }), - ) - if (!failed) hookFailures.delete(hookID) - } - return output - }) - - const list = Effect.fn("Plugin.list")(function* () { - const s = yield* InstanceState.get(state) - return s.hooks - }) - - const init = Effect.fn("Plugin.init")(function* () { - yield* InstanceState.get(state) - yield* InstanceState.get(fileHookState) - }) - - const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { - yield* InstanceState.invalidate(fileHookState) - }) - - return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) - -export * as Plugin from "." +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, + ActorPreStopInput, + ActorPostStopInput, + ActorStopOutput, + ActorMatcher, +} from "@mimo-ai/plugin" +import { z } from "zod" +import { matchesActor } from "./matcher" +import { Config } from "../config" +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Log } from "../util" +import { createOpencodeClient } from "@mimo-ai/sdk" +import { Flag } from "../flag/flag" +import { CodexAuthPlugin } from "./codex" +import { XaiAuthPlugin } from "./xai" +import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" +import { Session } from "../session" +import type { SessionID } from "../session/schema" +import { NamedError } from "@mimo-ai/shared/util/error" +import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" +import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" +import { Effect, Layer, Context, Stream } from "effect" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" +import { errorMessage } from "@/util/error" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" +import { Glob } from "@mimo-ai/shared/util/glob" +import fs from "fs" +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" + +const log = Log.create({ service: "plugin" }) + +export const HookEvent = { + Executed: BusEvent.define( + "hook.executed", + z.object({ + event: z.enum(["actor.preStop", "actor.postStop"]), + hookID: z.string(), + pluginName: z.string(), + actorID: z.string(), + agentType: z.string(), + durationMs: z.number(), + outcome: z.enum(["success", "error", "skipped"]), + continueRequested: z.boolean(), + reasonLength: z.number(), + }), + ), + ReActReentered: BusEvent.define( + "hook.react.reentered", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + iteration: z.number(), + triggeredByPlugins: z.array(z.string()), + reasonPreview: z.string(), + }), + ), + ReActMaxReached: BusEvent.define( + "hook.react.max_reached", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + }), + ), +} as const + +type HookEntry = { + hook: Hooks + pluginName: string + /** Stable per-event hook ID: `${pluginName}#${eventName}` */ + hookIDFor: (eventName: string) => string +} + +type State = { + hooks: Hooks[] + hooksWithMeta: HookEntry[] +} + +type FileHookState = { + hooks: Hooks[] + meta: HookEntry[] + dirs: string[] + /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ + files: Record + /** Mutable box: last staleness check timestamp (throttle). */ + lastCheck: { value: number } +} + +const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" +const FILE_HOOK_CHECK_INTERVAL_MS = 500 + +export type ActorStopAggregatedDecision = ActorStopOutput & { + contributingPluginNames: string[] + contributingHookIDs: string[] +} + +// Hook names that follow the (input, output) => Promise trigger pattern +type TriggerName = { + [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never +}[keyof Hooks] + +export interface Interface { + readonly trigger: < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect + readonly reloadFileHooks: () => Effect.Effect + readonly triggerActorPreStop: (input: ActorPreStopInput) => Effect.Effect + readonly triggerActorPostStop: (input: ActorPostStopInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Plugin") {} + +// Built-in plugins that are directly imported (not installed from npm) +const INTERNAL_PLUGINS: PluginInstance[] = [ + MimoAuthPlugin, + AnthropicProxyPlugin, + CodexAuthPlugin, + XaiAuthPlugin, + CopilotAuthPlugin, + // gitlab/poe auth are external npm packages typed against the published + // upstream plugin package, which carries a duplicate (nominal) copy of the + // SDK client; cast through unknown to the workspace Plugin type. + GitlabAuthPlugin as unknown as PluginInstance, + PoeAuthPlugin as unknown as PluginInstance, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + CheckpointSplitoverPlugin, + SubagentProgressCheckerPlugin, +] + +function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server +} + +function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) continue + result.push(plugin) + } + + return result +} + +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[], hooksWithMeta: HookEntry[]) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + if (plugin) { + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec + const hookObj = await (plugin as PluginModule).server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + return + } + + for (const server of getLegacyPlugins(load.mod)) { + const fnName = (server as { name?: string }).name + const pluginName = fnName && fnName !== "default" && fnName !== "" ? fnName : (load.pkg?.pkg ?? load.spec) + const hookObj = await server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("Plugin.state")(function* (ctx) { + const hooks: Hooks[] = [] + const hooksWithMeta: HookEntry[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } + + const { Server } = yield* Effect.promise(() => import("../server/server")) + + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: ctx.directory, + headers: Flag.MIMOCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, + } + : undefined, + fetch: async (...args) => (await Server.Default()).app.fetch(...args), + }) + const cfg = yield* config.get() + const input: PluginInput = { + client, + project: ctx.project, + worktree: ctx.worktree, + directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, + } + + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = yield* Effect.tryPromise({ + try: () => plugin(input), + catch: (err) => { + log.error("failed to load internal plugin", { name: plugin.name, error: err }) + }, + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: plugin.name, + hookIDFor: (event: string) => `${plugin.name}#${event}`, + }) + } + } + + // Load optional local extensions under src/ext/. Prefers the generated + // _manifest.ts (a fixed import specifier resolves inside Bun single-file + // executables, where filesystem scans do not); falls back to a directory + // scan for unbundled runs. Each *Plugin-named export is registered. + const extModules: Record> = {} + // @ts-ignore generated manifest; may not exist at type-check time + const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) + if (manifest._tag === "Some") { + Object.assign( + extModules, + (manifest.value as { modules?: Record> }).modules ?? {}, + ) + } else { + const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") + const extFiles = fs.existsSync(extDir) + ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") + : [] + for (const entry of extFiles) { + const mod = yield* Effect.tryPromise({ + try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), + catch: (err) => log.error("failed to import extension", { name: entry, error: err }), + }).pipe(Effect.option) + if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record + } + } + for (const [name, value] of Object.entries(extModules)) { + // Only treat *Plugin-named function exports as plugins. Other modules + // (e.g. a CLI helper export) are not plugins and must not be invoked + // as plugin factories. + const overlay = Object.entries(value).find( + ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), + )?.[1] as PluginInstance | undefined + if (!overlay) continue + log.info("loading extension", { name }) + const init = yield* Effect.tryPromise({ + try: () => overlay(input), + catch: (err) => log.error("failed to load extension", { name, error: err }), + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: name, + hookIDFor: (event: string) => `${name}#${event}`, + }) + } + } + + const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) + if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) + } + if (plugins.length) yield* config.waitForDependencies() + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) { + log.info("loading plugin", { path: candidate.plan.spec }) + }, + missing(candidate, _retry, message) { + log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) + }, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) + + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + log.warn("plugin incompatible", { path: spec, error: message }) + publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + log.error("failed to resolve plugin server entry", { path: spec, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks, hooksWithMeta), + catch: (err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: load.spec, error: message }) + return message + }, + }).pipe( + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), + ) + } + + // Notify plugins of current config + for (const hook of hooks) { + yield* Effect.tryPromise({ + try: () => Promise.resolve((hook as any).config?.(cfg)), + catch: (err) => { + log.error("plugin config hook failed", { error: err }) + }, + }).pipe(Effect.ignore) + } + + // Subscribe to bus events, fiber interrupted when scope closes + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: input as any }) + } + }), + ), + Effect.forkScoped, + ) + + return { hooks, hooksWithMeta } + }), + ) + + const fileHookState = yield* InstanceState.make( + Effect.fn("Plugin.fileHooks")(function* () { + const hooks: Hooks[] = [] + const meta: HookEntry[] = [] + const files: Record = {} + yield* config.get() + const dirs = yield* config.directories() + + for (const dir of dirs) { + const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) + for (const match of matches) { + const stat = yield* Effect.tryPromise({ + try: () => fs.promises.stat(match), + catch: (err) => err, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + files[match] = stat?.mtimeMs ?? 0 + // Transpile and load the hook file. We use Bun.build to produce a + // temporary .js artifact, then dynamic-import that artifact. This + // avoids two pitfalls: (1) Bun's import() ignores query-string cache + // busters so re-imports return stale modules, (2) require() transpiles + // .ts in some contexts but not others (CI Linux edge case). + const mod = yield* Effect.tryPromise({ + try: async () => { + const result = await Bun.build({ + entrypoints: [match], + target: "bun", + format: "esm", + }) + if (!result.success) throw new Error(result.logs.map(String).join("\n")) + const blob = result.outputs[0] + const tmpFile = `${match}.${Date.now()}.mjs` + await Bun.write(tmpFile, blob) + try { + return (await import(tmpFile)) as Record + } finally { + fs.promises.unlink(tmpFile).catch(() => {}) + } + }, + catch: (err) => err, + }).pipe( + Effect.catch((err) => { + log.error("failed to load file hook", { path: match, error: errorMessage(err) }) + return Effect.succeed(undefined) + }), + ) + if (!mod) continue + const hookObj: Hooks = (mod.default ?? mod) as Hooks + if (hookObj && typeof hookObj === "object") { + const name = path.basename(match, path.extname(match)) + hooks.push(hookObj) + meta.push({ + hook: hookObj, + pluginName: `file:${name}`, + hookIDFor: (event: string) => `file:${name}#${event}`, + }) + log.info("loaded file hook", { path: match, name }) + } + } + } + + // Dispatch bus events to file hooks' `event` handlers. Scoped to this + // cache entry: invalidation interrupts the fiber, and the rebuild + // re-subscribes with the fresh hook set. + if (hooks.some((hook) => typeof hook.event === "function")) { + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const entry of meta) { + const fn = entry.hook.event + if (!fn) continue + try { + void Promise.resolve(fn({ event: input as any })).catch((err) => { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + }) + } catch (err) { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + } + } + }), + ), + Effect.forkScoped, + ) + } + + return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } + }), + ) + + // Staleness check: re-stat known hook files and re-glob hook dirs. Any + // mtime change, added, or removed file invalidates the cache so the next + // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other + // processes) — not just this process's write/edit tools. Throttled to + // avoid stat storms on hot trigger paths. + const freshFileHooks = Effect.gen(function* () { + const fh = yield* InstanceState.get(fileHookState) + const now = Date.now() + if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh + fh.lastCheck.value = now + + const stale = yield* Effect.promise(async () => { + const known = Object.keys(fh.files) + const seen = new Set() + for (const dir of fh.dirs) { + for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { + seen.add(match) + if (!(match in fh.files)) return true + } + } + for (const file of known) { + if (!seen.has(file)) return true + const stat = await fs.promises.stat(file).catch(() => undefined) + if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true + } + return false + }) + + if (!stale) return fh + log.info("file hooks changed on disk, reloading") + yield* InstanceState.invalidate(fileHookState) + return yield* InstanceState.get(fileHookState) + }) + + const aggregateDecision = ( + input: ActorPreStopInput | ActorPostStopInput, + eventName: "actor.preStop" | "actor.postStop", + ) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + const reasons: string[] = [] + const pluginNames: string[] = [] + const hookIDs: string[] = [] + let anyContinue = false + + for (const entry of [...s.hooksWithMeta, ...fh.meta]) { + const reg = entry.hook[eventName] + if (!reg) continue + + const fn = typeof reg === "function" ? reg : reg.run + const matcher: ActorMatcher | undefined = typeof reg === "function" ? undefined : reg.matcher + + if (!matchesActor(matcher, input)) { + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs: 0, + outcome: "skipped", + continueRequested: false, + reasonLength: 0, + }) + continue + } + + const startedAt = Date.now() + const o: ActorStopOutput = { continue: false } + let hookOutcome: "success" | "error" = "success" + // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative + // cancellation into their fetch / DB calls. Effect interrupt only stops + // the awaiting fiber — the underlying Promise keeps running and may + // bus.publish events after the actor has been cleaned up. See spec + // Future work for full discussion. Strict in-process cancellation + // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. + yield* Effect.tryPromise({ + try: () => fn(input as never, o), + catch: (err) => err, + }).pipe( + Effect.tapError((err) => + Effect.gen(function* () { + hookOutcome = "error" + log.error(`${eventName} hook failed`, { + pluginName: entry.pluginName, + hookID: entry.hookIDFor(eventName), + error: err, + }) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID as SessionID, + error: new NamedError.Unknown({ + message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, + }).toObject(), + }) + }), + ), + Effect.ignore, + ) + + const durationMs = Date.now() - startedAt + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs, + outcome: hookOutcome, + continueRequested: o.continue === true, + reasonLength: o.reason?.length ?? 0, + }) + + if (o.continue === true && o.reason && o.reason.length > 0) { + anyContinue = true + reasons.push(o.reason) + pluginNames.push(entry.pluginName) + hookIDs.push(entry.hookIDFor(eventName)) + } else if (o.continue === true) { + log.warn(`${eventName} hook returned continue=true without reason; ignored`, { + pluginName: entry.pluginName, + }) + } + } + + const aggregated: ActorStopAggregatedDecision = { + continue: anyContinue, + reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, + contributingPluginNames: pluginNames, + contributingHookIDs: hookIDs, + } + return aggregated + }) + + const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* (input: ActorPreStopInput) { + return yield* aggregateDecision(input, "actor.preStop") + }) + + const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* (input: ActorPostStopInput) { + return yield* aggregateDecision(input, "actor.postStop") + }) + + const HOOK_TIMEOUT_MS = 5000 + const CIRCUIT_BREAKER_THRESHOLD = 3 + const hookFailures = new Map() + + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + + for (const entry of s.hooksWithMeta) { + const fn = entry.hook[name] as any + if (!fn) continue + yield* Effect.promise(async () => fn(input, output)) + } + + for (const entry of fh.meta) { + const fn = entry.hook[name] as any + if (!fn) continue + const hookID = entry.hookIDFor(name) + + if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { + log.warn("hook circuit-breaker open, skipping", { hook: hookID }) + continue + } + + const snapshot = structuredClone(output) + const failed = yield* Effect.tryPromise({ + try: async () => { + await Promise.race([ + Promise.resolve(fn(input, output)), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), + ), + ]) + }, + catch: (err) => err, + }).pipe( + Effect.map(() => false), + Effect.catch((err) => { + Object.assign(output as any, snapshot) + const count = (hookFailures.get(hookID) ?? 0) + 1 + hookFailures.set(hookID, count) + log.error("file hook failed, output rolled back", { + hook: hookID, + event: name, + error: errorMessage(err), + consecutiveFailures: count, + circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, + }) + return Effect.succeed(true) + }), + ) + if (!failed) hookFailures.delete(hookID) + } + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* InstanceState.get(state) + yield* InstanceState.get(fileHookState) + }) + + const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { + yield* InstanceState.invalidate(fileHookState) + }) + + return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + +export * as Plugin from "." From 70f0eb6859b6e35306c30977b3d8535b5eef1ac4 Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:41:04 +0200 Subject: [PATCH 3/4] ci: comprehensive CI/CD pipeline with multi-language support Replaces separate lint.yml, test.yml, typecheck.yml with unified ci.yml: - typecheck: bun typecheck - lint: oxlint + prettier check - test: sharded unit tests (4 shards) - security: skylos scan on plugin directory - quality: repowise health on plugin directory - python: auto-detects pyproject.toml, runs ruff/mypy/pytest - rust: auto-detects Cargo.toml, runs cargo check/test - go: auto-detects go.mod, runs go vet/test Multi-language: Python, Rust, Go jobs auto-detect and skip if no files found. --- .github/workflows/ci.yml | 173 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..eafc262c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,173 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: true + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Run typecheck + run: bun typecheck + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Run oxlint + run: bun lint + - name: Check formatting + run: npx prettier --check "packages/opencode/src/**/*.ts" "packages/opencode/test/**/*.ts" + + test: + name: Test (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: ["1/4", "2/4", "3/4", "4/4"] + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Configure git identity + run: | + git config --global user.email "ci@mimo.ai" + git config --global user.name "mimo-ci" + - name: Run unit tests (shard ${{ matrix.shard }}) + timeout-minutes: 8 + working-directory: packages/opencode + run: bun run test:ci --shard ${{ matrix.shard }} + - name: Upload JUnit + if: always() + uses: actions/upload-artifact@v7 + with: + name: junit-shard-${{ strategy.job-index }} + path: packages/opencode/.artifacts/unit/junit.xml + + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Install skylos + run: pip install skylos + - name: Run skylos security scan + run: skylos suite packages/opencode/src/plugin --json || true + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: skylos-results + path: packages/opencode/.artifacts/security/ + + quality: + name: Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Install repowise + run: pip install repowise + - name: Run repowise health + run: repowise health packages/opencode/src/plugin --json || true + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: repowise-results + path: packages/opencode/.artifacts/quality/ + + # Multi-language support: detect and test additional languages + python: + name: Python (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for Python files + id: check + run: | + if find . -name "pyproject.toml" -o -name "setup.py" -o -name "requirements.txt" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Python + if: steps.check.outputs.found == 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install Python deps + if: steps.check.outputs.found == 'true' + run: | + pip install ruff mypy pytest + - name: Run ruff + if: steps.check.outputs.found == 'true' + run: ruff check . + - name: Run mypy + if: steps.check.outputs.found == 'true' + run: mypy . --ignore-missing-imports + - name: Run pytest + if: steps.check.outputs.found == 'true' + run: python -m pytest tests/ -x --timeout=60 || true + + rust: + name: Rust (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for Cargo.toml + id: check + run: | + if find . -name "Cargo.toml" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Rust + if: steps.check.outputs.found == 'true' + uses: dtolnay/rust-toolchain@stable + - name: Cargo check + if: steps.check.outputs.found == 'true' + run: cargo check + - name: Cargo test + if: steps.check.outputs.found == 'true' + run: cargo test --no-fail-fast || true + + go: + name: Go (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for go.mod + id: check + run: | + if find . -name "go.mod" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Go + if: steps.check.outputs.found == 'true' + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Go vet + if: steps.check.outputs.found == 'true' + run: go vet ./... + - name: Go test + if: steps.check.outputs.found == 'true' + run: go test ./... || true From b890aca6de060f5c4dbb33793cdc5476d6c9ea6d Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:45:47 +0200 Subject: [PATCH 4/4] ci: remove old workflows (replaced by ci.yml) --- .github/workflows/lint.yml | 21 --------------- .github/workflows/test.yml | 47 --------------------------------- .github/workflows/typecheck.yml | 21 --------------- 3 files changed, 89 deletions(-) delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .github/workflows/typecheck.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e68c4803c..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: lint - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run oxlint - run: bun lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index c601b0d60..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: test - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - unit: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: ["1/4", "2/4", "3/4", "4/4"] - name: unit (shard ${{ matrix.shard }}) - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Configure git identity - run: | - git config --global user.email "ci@mimo.ai" - git config --global user.name "mimo-ci" - - - name: Run unit tests (shard ${{ matrix.shard }}) - timeout-minutes: 8 - working-directory: packages/opencode - run: bun run test:ci --shard ${{ matrix.shard }} - - - name: Upload JUnit - if: always() - uses: actions/upload-artifact@v7 - with: - name: junit-shard-${{ strategy.job-index }} - path: packages/opencode/.artifacts/unit/junit.xml diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index 903ca0eba..000000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: typecheck - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -jobs: - typecheck: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run typecheck - run: bun typecheck