diff --git a/.opencode/plugins/sample-agent.ts b/.opencode/plugins/sample-agent.ts new file mode 100644 index 0000000000..a95b2af959 --- /dev/null +++ b/.opencode/plugins/sample-agent.ts @@ -0,0 +1,16 @@ +export default { + id: "sample-agent-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("sample-plugin-agent", (agent) => { + agent.description = "Example subagent registered by .opencode/plugins/sample-agent.ts" + agent.mode = "subagent" + agent.prompt = [ + "You are the sample plugin agent for this repository.", + "Use this agent to verify that local plugin auto-discovery can add agents.", + "Keep responses concise and explain which plugin registered you when asked.", + ].join("\n") + }) + }) + }, +} diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 3cbeded098..c21bb9d977 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -38,7 +38,7 @@ export default Runtime.handler( createOpencodeClient({ baseUrl: HttpServer.formatAddress(address), headers: ServerAuth.headers({ password }), - }).v2.location.get(undefined, { throwOnError: true }), + }).v2.health.get({}), ) if (input.service) yield* daemon.register(address) const url = HttpServer.formatAddress(address) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index b5163329c9..22f49bbf16 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -29,6 +29,12 @@ const PluginModule = Schema.Struct({ ]), }) +const PluginPackage = Schema.Struct({ + exports: Schema.optional(Schema.Unknown), + main: Schema.optional(Schema.String), + module: Schema.optional(Schema.String), +}) + export const Plugin = define({ id: "config-plugin", effect: Effect.fn(function* (ctx) { @@ -65,8 +71,30 @@ export const Plugin = define({ symlink: true, }) .pipe(Effect.orElseSucceed(() => [])) + const directories = yield* fs + .glob("{plugin,plugins}/*", { + cwd: entry.path, + absolute: true, + include: "all", + dot: true, + symlink: true, + }) + .pipe( + Effect.flatMap((items) => + Effect.filter(items, (item) => fs.isDir(item), { + concurrency: "unbounded", + }), + ), + Effect.orElseSucceed(() => []), + ) + const packages = yield* Effect.forEach( + directories.sort(), + (directory) => resolvePackageEntrypoint(fs, directory), + { concurrency: "unbounded" }, + ).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) files.sort() for (const file of files) configured.push({ package: file }) + for (const file of packages) configured.push({ package: file }) } } @@ -89,3 +117,18 @@ export const Plugin = define({ }) }), }) + +const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { + const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), + Effect.catch(() => Effect.succeed(undefined)), + ) + const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined + const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] + + return yield* Effect.forEach(entries, (entry) => { + if (!entry) return Effect.succeed(undefined) + const file = path.resolve(directory, entry) + return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) + }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) +}) diff --git a/packages/core/src/observability/logging.ts b/packages/core/src/observability/logging.ts index 0047d8d5e3..1588c6f764 100644 --- a/packages/core/src/observability/logging.ts +++ b/packages/core/src/observability/logging.ts @@ -1,6 +1,7 @@ import { Formatter, Logger, type LogLevel } from "effect" import path from "path" import { Global } from "../global" +import { InstallationChannel, InstallationLocal } from "../installation/version" import { runID } from "./shared" function formatter(id: string = runID) { @@ -46,9 +47,14 @@ function format(input: unknown) { return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value) } -export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) { +export function file(local = InstallationLocal, channel = InstallationChannel) { + if (!local) return path.join(Global.Path.log, "opencode.log") + return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`) +} + +export function fileLogger(target = file(), id: string = runID) { // Do not set batchWindow to 0; it causes high idle CPU usage. - return Logger.toFile(formatter(id), file, { flag: "a" }) + return Logger.toFile(formatter(id), target, { flag: "a" }) } const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n")) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 1adc979d25..f499fbafbd 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,4 +1,5 @@ import { define } from "./internal" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" import { ModelV2 } from "../model" @@ -45,6 +46,15 @@ function variants(model: ModelsDev.Model) { })) } +function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) { + const existing = new Map(model.variants.map((variant) => [variant.id, variant])) + const nextIDs = new Set(next.map((variant) => variant.id)) + model.variants = [ + ...next.map((variant) => existing.get(variant.id) ?? variant), + ...model.variants.filter((variant) => !nextIDs.has(variant.id)), + ] +} + export const ModelsDevPlugin = define({ id: "models-dev", effect: Effect.fn(function* (ctx) { @@ -111,7 +121,7 @@ export const ModelsDevPlugin = define({ input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } - draft.variants = variants(model) + mergeVariants(draft, variants(model)) draft.time.released = released(model.release_date) draft.cost = cost(model.cost) draft.status = model.status ?? "active" diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index f07d72e5f4..9b37b54ed1 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -142,11 +142,16 @@ export const OpencodePlugin = define ({ - id: ModelV2.VariantID.make(id), - headers: { ...(options.headers ?? {}) }, - body: lowerer.request(withoutCredentials(options)), - })) + for (const [id, options] of Object.entries(config.variants)) { + const variantID = ModelV2.VariantID.make(id) + let existing = model.variants.find((item) => item.id === variantID) + if (!existing) { + existing = { id: variantID, headers: {}, body: {} } + model.variants.push(existing) + } + Object.assign(existing.headers, options.headers) + Object.assign(existing.body, lowerer.request(withoutCredentials(options))) + } } if (config.release_date !== undefined) { const released = Date.parse(config.release_date) diff --git a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts new file mode 100644 index 0000000000..afa7f5c37e --- /dev/null +++ b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "folder-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("folder", (agent) => { + agent.description = "Loaded from plugin folder" + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 2e134c55e1..b72a62df8a 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -234,6 +234,10 @@ describe("ConfigExternalPlugin", () => { description: "Loaded from plugin directory", mode: "subagent", }) + expect(yield* waitForAgent(agents, "folder")).toMatchObject({ + description: "Loaded from plugin folder", + mode: "subagent", + }) }), ) }) diff --git a/packages/core/test/logging.test.ts b/packages/core/test/logging.test.ts new file mode 100644 index 0000000000..6ac19ab9e1 --- /dev/null +++ b/packages/core/test/logging.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Global } from "@opencode-ai/core/global" +import { Logging } from "@opencode-ai/core/observability/logging" + +describe("Logging", () => { + test("uses a local-specific log file for local installs", () => { + expect(Logging.file(true, "local")).toBe(path.join(Global.Path.log, "opencode-local.log")) + }) + + test("keeps non-local installs on the default log file", () => { + expect(Logging.file(false, "next")).toBe(path.join(Global.Path.log, "opencode.log")) + }) +}) diff --git a/packages/core/test/plugin/fixtures/models-dev.json b/packages/core/test/plugin/fixtures/models-dev.json index fb8f2622be..41cb238176 100644 --- a/packages/core/test/plugin/fixtures/models-dev.json +++ b/packages/core/test/plugin/fixtures/models-dev.json @@ -9,6 +9,27 @@ "id": "local", "name": "Local", "env": [], - "models": {} + "models": { + "model": { + "id": "model", + "name": "Local Model", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": { "context": 1000, "output": 100 }, + "experimental": { + "modes": { + "high": { + "provider": { "body": { "reasoning_effort": "high" } } + }, + "max": { + "provider": { "headers": { "x-variant": "max" }, "body": { "reasoning_effort": "max" } } + } + } + } + } + } } } diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 4c3071c774..9d0f28d76b 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -7,9 +7,11 @@ import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" import { Policy } from "@opencode-ai/core/policy" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -73,4 +75,48 @@ describe("ModelsDevPlugin", () => { }), ), ) + + it.effect("loads models.dev variants without replacing existing variants", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = { + path: Flag.OPENCODE_MODELS_PATH, + disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH, + } + Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json") + Flag.OPENCODE_DISABLE_MODELS_FETCH = true + return previous + }), + () => + Effect.gen(function* () { + const service = yield* Catalog.Service + const integrations = yield* Integration.Service + yield* service.transform((catalog) => { + catalog.model.update(ProviderV2.ID.make("local"), ModelV2.ID.make("model"), (model) => { + model.variants = [ + { id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }, + { id: ModelV2.VariantID.make("custom"), headers: {}, body: { custom: true } }, + ] + }) + }) + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(service), + integration: integrationHost(integrations), + }), + ) + + expect((yield* service.model.get(ProviderV2.ID.make("local"), ModelV2.ID.make("model")))?.variants).toEqual([ + expect.objectContaining({ id: "high", headers: { custom: "true" } }), + expect.objectContaining({ id: "max", headers: { "x-variant": "max" }, body: { reasoning_effort: "max" } }), + expect.objectContaining({ id: "custom", body: { custom: true } }), + ]) + }).pipe(Effect.provide(ModelsDev.defaultLayer)), + (previous) => + Effect.sync(() => { + Flag.OPENCODE_MODELS_PATH = previous.path + Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled + }), + ), + ) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 45d423f958..d35e69850d 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -133,6 +133,15 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((draft) => { draft.provider.update(ProviderV2.ID.make("remote"), () => {}) + draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"), (model) => { + model.variants = [ + { + id: ModelV2.VariantID.make("custom"), + headers: { "x-custom": "true" }, + body: { custom: true }, + }, + ] + }) draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {}) }) yield* credentials.create({ @@ -176,6 +185,11 @@ describe("OpencodePlugin", () => { }) expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 }) expect(model.variants).toEqual([ + { + id: ModelV2.VariantID.make("custom"), + headers: { "x-custom": "true" }, + body: { custom: true }, + }, { id: ModelV2.VariantID.make("high"), headers: {}, diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 969d1664ed..89985ff92a 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1148,7 +1148,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi - + diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 6522170845..bd444ea1ef 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -93,6 +93,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + let bootstrapping: Promise | undefined const message = { update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { @@ -543,6 +544,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": + if (bootstrapping) break void result.location.mcp.refresh(event.location) break } @@ -750,7 +752,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } async function bootstrap() { - const settled = await Promise.allSettled([ + if (bootstrapping) return bootstrapping + bootstrapping = Promise.allSettled([ sdk.api.session .list({ limit: 50, @@ -787,14 +790,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.skill.refresh(), result.shell.refresh(), ]) - for (const failure of settled.filter((item) => item.status === "rejected")) - console.error("Failed to refresh default location data", failure.reason) + .then((settled) => { + for (const failure of settled.filter((item) => item.status === "rejected")) + console.error("Failed to refresh default location data", failure.reason) + }) + .finally(() => { + bootstrapping = undefined + }) + return bootstrapping } onCleanup( sdk.event.listen(({ details }) => { + if (details.type === "server.connected") { + void bootstrap() + return + } handleEvent(details) - if (details.type === "server.connected") void bootstrap() }), ) diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 6750419cfd..0d5cce8a8f 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting" +export type SDKConnectionStatus = "connected" | "connecting" type SDKEventMap = { [Type in V2Event["type"]]: Extract } const connectTimeout = 2_000 @@ -64,10 +64,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return connection.signal.reason instanceof Error ? connection.signal.reason : new Error("Event stream disconnected") + if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected") clearTimeout(timeout) attempt = 0 - setConnection({ status: "connected", attempt: 0, error: undefined }) events.emit(first.value.type, first.value) + setConnection({ status: "connected", attempt: 0, error: undefined }) connected() while (!abort.signal.aborted && !controller.signal.aborted) { const event = await iterator.next() @@ -84,7 +85,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (abort.signal.aborted || controller.signal.aborted) return attempt += 1 setConnection({ - status: "reconnecting", + status: "connecting", attempt, error: error instanceof Error ? error.message : String(error), }) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0893003964..b7893d8abd 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -155,7 +155,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { expect(data.connection.attempt()).toBe(0) events.disconnect() - await wait(() => data.connection.status() === "reconnecting") + await wait(() => data.connection.status() === "connecting") expect(data.connection.attempt()).toBe(1) expect(data.connection.error()).toBe("Event stream disconnected")