Compare commits

...

17 Commits

Author SHA1 Message Date
Dax Raad 393666a08d test(tui): handle plugin inventory requests 2026-08-18 15:20:23 -04:00
Dax Raad 9f7f366b69 feat(cli): manage plugin packages 2026-08-18 15:03:50 -04:00
opencode-agent[bot] 02f3f3cb3e test(core): remove flaky webfetch checks (#43278)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-08-18 20:26:26 +02:00
Dax b0c3a16ead feat(plugin): expose server plugin status 2026-08-18 14:22:32 -04:00
Filip 594c395576 fix(tui): restore MCP sign-in flow (#43274) 2026-08-18 20:02:10 +02:00
Dax b7402c264d fix(core): reduce noisy tracing spans
Reduce low-value tracing emitted during parallel tool execution and suppress per-statement SQL spans.
2026-08-18 13:42:50 -04:00
Dax 3f79699bce feat(core): persist websearch provider selection (#43268) 2026-08-18 17:32:08 +00:00
Dax 67fe76057e feat(cli): configure service environment (#43269) 2026-08-18 13:27:44 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
Aiden Cline 98c717cb5b fix(core): migrate standalone small model (#43260) 2026-08-18 11:14:36 -05:00
Major Hayden 5c8d46ab4b fix(core): make Google Vertex models work with ADC credentials (#43077)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-18 10:47:13 -05:00
Dax Raad d5e83fefda fix(core): reuse prompt cache for forks 2026-08-18 11:38:11 -04:00
Dax Raad 46378dda50 refactor(core): standardize builtin plugin ids 2026-08-18 11:07:11 -04:00
Dax b38d9d812f refactor(client): move service shutdown client-side (#43252) 2026-08-18 15:02:37 +00:00
Dax Raad 8df039d261 fix(cli): limit source maps to development channels 2026-08-18 10:53:42 -04:00
Dax Raad c92fb2d41b fix(cli): improve process failure logging 2026-08-18 10:49:52 -04:00
Shoubhit Dash 958308c913 fix(core): ignore malformed model costs (#43251) 2026-08-18 19:59:47 +05:30
113 changed files with 1835 additions and 847 deletions
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
+8
View File
@@ -0,0 +1,8 @@
import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+1 -1
View File
@@ -109,7 +109,7 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: "inline",
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
+7 -12
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bun
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect/service"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -63,28 +64,22 @@ try {
})
if (unauthorizedOpenApi.status !== 401)
throw new Error("Compiled service exposed application routes without authentication")
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { "content-type": "application/json" },
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
const winner = processes.find((process) => process.pid === info.pid)
const loser = processes.find((process) => process.pid !== info.pid)
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
}).then((response) => response.json()),
await Effect.runPromise(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
)
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
+47 -4
View File
@@ -160,7 +160,29 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
commands: [
Spec.make("list", {
description: "List plugins",
params: {
builtin: Flag.boolean("builtin").pipe(
Flag.withDescription("Include built-in server plugins"),
Flag.withDefault(false),
),
},
}),
Spec.make("add", {
description: "Install a plugin and add it to the global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
},
}),
],
}),
Spec.make("models", {
description: "List all available models",
@@ -275,15 +297,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("get", {
description: "Get service configuration",
params: { key: Argument.string("key").pipe(Argument.optional) },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
Spec.make("set", {
description: "Set service configuration",
params: { key: Argument.string("key"), value: Argument.string("value") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
value: Argument.string("value").pipe(
Argument.withDescription("Setting value or environment variable name"),
),
nestedValue: Argument.string("env-value").pipe(
Argument.withDescription("Environment variable value"),
Argument.optional,
),
},
}),
Spec.make("unset", {
description: "Unset service configuration",
params: { key: Argument.string("key") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
],
}),
@@ -84,8 +84,12 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -0,0 +1,82 @@
import { EOL } from "node:os"
import path from "node:path"
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
import { Config } from "../../../config"
export default Runtime.handler(
Commands.commands.plugin.commands.add,
Effect.fn("cli.plugin.add")(function* (input) {
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
return yield* Effect.fail(
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
)
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
if (target === "server") {
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
const changed = yield* Effect.promise(() => writePluginConfig(configPath, input.package))
process.stdout.write(
changed
? `Plugin "${input.package}" installed and added to ${configPath}${EOL}`
: `Plugin "${input.package}" is already configured in ${configPath}${EOL}`,
)
return
}
const config = yield* Config.Service
yield* config.update((draft) => {
if (configured(draft.plugins, input.package)) return
draft.plugins = [...(draft.plugins ?? []), input.package]
})
process.stdout.write(`TUI plugin "${input.package}" installed and added to ${config.path}${EOL}`)
}),
)
export function configurationTarget(server?: string, tui?: string) {
if (server) return "server" as const
if (tui) return "tui" as const
}
export async function writePluginConfig(configPath: string, spec: string) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
throw error
})
const errors: ParseError[] = []
const config: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
throw new Error(`Invalid global configuration: ${configPath}`)
const plugins = "plugins" in config ? config.plugins : undefined
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
if (configured(plugins, spec)) return false
const updated = applyEdits(
text,
modify(text, ["plugins"], [...(plugins ?? []), spec], { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
await mkdir(path.dirname(configPath), { recursive: true })
const temporary = configPath + ".tmp"
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
await rename(temporary, configPath)
return true
}
function configured(plugins: readonly unknown[] | undefined, spec: string) {
return plugins?.some(
(entry) =>
entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec),
)
}
@@ -1,24 +1,76 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* () {
Effect.fn("cli.plugin.list")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
const config = yield* Config.Service
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const output = format(
response.data,
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
input.builtin,
)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
process.stdout.write(output + EOL)
}),
)
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -0,0 +1,74 @@
import { EOL } from "node:os"
import path from "node:path"
import { readFile, rename, writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Config } from "../../../config"
import { resolveConfigPath } from "../mcp/add"
export default Runtime.handler(
Commands.commands.plugin.commands.remove,
Effect.fn("cli.plugin.remove")(function* (input) {
const global = yield* Global.Service
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
const server = yield* Effect.promise(() => removePluginConfig(configPath, input.package))
const config = yield* Config.Service
const info = yield* config.get()
const tui = configured(info.plugins, input.package)
if (tui)
yield* config.update((draft) => {
draft.plugins = draft.plugins?.filter((entry) => !matches(entry, input.package))
})
const removed = [server ? configPath : undefined, tui ? config.path : undefined].filter(
(file) => file !== undefined,
)
process.stdout.write(
removed.length
? `Plugin "${input.package}" removed from ${removed.join(", ")}${EOL}`
: `Plugin "${input.package}" is not configured${EOL}`,
)
}),
)
export async function removePluginConfig(configPath: string, spec: string) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined
throw error
})
if (text === undefined) return false
const errors: ParseError[] = []
const config: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
throw new Error(`Invalid global configuration: ${configPath}`)
const plugins = "plugins" in config ? config.plugins : undefined
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
if (!configured(plugins, spec)) return false
const updated = applyEdits(
text,
modify(
text,
["plugins"],
plugins?.filter((entry) => !matches(entry, spec)),
{
formattingOptions: { tabSize: 2, insertSpaces: true },
},
),
)
const temporary = configPath + ".tmp"
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
await rename(temporary, configPath)
return true
}
function configured(plugins: readonly unknown[] | undefined, spec: string) {
return plugins?.some((entry) => matches(entry, spec)) ?? false
}
function matches(entry: unknown, spec: string) {
return entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec)
}
@@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) {
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
process.stdout.write(
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
)
}),
)
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) {
yield* ServiceConfig.set(input.key, input.value)
yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue))
}),
)
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) {
yield* ServiceConfig.unset(input.key)
yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name))
}),
)
+23
View File
@@ -38,6 +38,8 @@ const Handlers = Runtime.handlers(Commands, {
},
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
@@ -59,6 +61,21 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
}
const unhandledRejection = (cause: unknown) => {
runFork(Effect.logError("unhandled rejection", { cause }))
}
process.on("uncaughtException", uncaughtException)
process.on("unhandledRejection", unhandledRejection)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.off("uncaughtException", uncaughtException)
process.off("unhandledRejection", unhandledRejection)
}),
)
yield* Effect.logInfo("cli starting", {
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
@@ -67,6 +84,12 @@ Effect.gen(function* () {
})
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("cli process failed", {
cause,
args: process.argv.slice(2),
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
+22 -12
View File
@@ -117,7 +117,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions === undefined
? undefined
: {
instanceID,
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
@@ -180,27 +179,38 @@ const register = Effect.fnUntraced(function* (
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.tapError(() =>
Effect.logWarning("managed service registration lost; shutting down", {
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: id,
servicePID: process.pid,
registration: file,
}),
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
+37 -8
View File
@@ -15,10 +15,11 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password"] as const
const keys = ["hostname", "port", "password", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -76,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -104,6 +105,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
env: (yield* read()).env,
command: [
...selfCommand(),
"serve",
@@ -141,12 +143,14 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
return next
})
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2)
}
switch (configKey(key)) {
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -157,12 +161,19 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
case "password": {
return yield* password()
}
case "env": {
const env = (yield* read()).env ?? {}
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
}
}
throw new Error(`Unknown service config key: ${key}`)
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
switch (configKey(key)) {
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) {
const selected = configKey(key)
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -180,11 +191,20 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* password(value)
return
}
case "env": {
if (nestedValue === undefined) throw new Error("Usage: opencode service set env <key> <value>")
yield* Service.stop(yield* options())
const existing = yield* read()
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
return
}
}
})
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
switch (configKey(key)) {
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) {
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
@@ -203,6 +223,15 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next)
return
}
case "env": {
if (name === undefined) throw new Error("Usage: opencode service unset env <key>")
yield* Service.stop(yield* options())
const existing = yield* read()
const { [name]: _removed, ...env } = existing.env ?? {}
const { env: _existingEnv, ...rest } = existing
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
return
}
}
})
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
test("routes packages according to their exported runtimes", () => {
expect(configurationTarget("server.js", "tui.js")).toBe("server")
expect(configurationTarget("server.js", undefined)).toBe("server")
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
expect(configurationTarget(undefined, undefined)).toBeUndefined()
})
test("adds a package to global plugin config without replacing unrelated settings", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "opencode.jsonc")
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
try {
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({
model: "provider/model",
plugins: ["first", "second@1.0.0"],
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test"
import { format } from "../src/commands/handlers/plugin/list"
test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
status: "active",
tui: true,
},
{
source: { type: "package", package: "broken-plugin" },
status: "failed",
error: "broken",
tui: false,
},
],
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
],
),
).toBe(`TUI
/tmp/local.ts (discovered)
acme-plugin@1.0.0 (advertised)
tui-only (configured)
Server
acme.dual (active)
broken-plugin (failed)`)
})
test("includes builtins when requested", () => {
expect(format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true))
.toBe(`Server
opencode.agent (active)`)
})
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
test("removes string and object package entries without replacing unrelated settings", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "opencode.jsonc")
await Bun.write(
file,
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
)
try {
expect(await removePluginConfig(file, "remove-me")).toBe(true)
expect(await removePluginConfig(file, "remove-me")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+38
View File
@@ -60,6 +60,44 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service config manages environment variables", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
try {
await Effect.runPromise(
ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(
await Effect.runPromise(
ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
),
).toBe("configured")
expect(
(
await Effect.runPromise(
ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)),
)
).env,
).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" })
await Effect.runPromise(
ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json")
-5
View File
@@ -41,13 +41,8 @@ import type { Config } from "@opencode-ai/schema/config"
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
export type Endpoint0_1Input = { readonly instanceID: string }
export type Endpoint0_1Output = { readonly accepted: boolean }
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
export interface HealthApi<E = never> {
readonly get: HealthGetOperation<E>
readonly stop: HealthStopOperation<E>
}
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
@@ -6,8 +6,6 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
Endpoint0_0Output,
Endpoint0_1Input,
Endpoint0_1Output,
Endpoint1_0Output,
Endpoint2_0Input,
Endpoint2_0Output,
@@ -248,12 +246,7 @@ const preserveStream =
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
preserveEffect<Endpoint0_1Output>()(
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
+14 -58
View File
@@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
return yield* Effect.try({
try: () => {
return spawnServiceContender(command, args)
return spawnServiceContender(command, args, options.env)
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options, timing)
yield* terminate(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
yield* kill(service, options, timing).pipe(Effect.ignore)
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
const info = yield* read(options.file)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
function fallback() {
@@ -243,12 +243,6 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
const poll = (timing: EnsureTiming) =>
@@ -269,59 +263,21 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
if (Option.isNone(done)) {
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
}
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const decoded = decodeStopResponse(body)
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
return "accepted" as const
const fs = yield* FileSystem.FileSystem
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
})
/** Effect-based local service lifecycle operations. */
@@ -1,7 +1,5 @@
import type {
HealthGetOutput,
HealthStopInput,
HealthStopOutput,
ServerGetOutput,
LocationGetInput,
LocationGetOutput,
@@ -367,18 +365,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
request<HealthStopOutput>(
{
method: "POST",
path: `/api/service/stop`,
body: { instanceID: input["instanceID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
server: {
get: (requestOptions?: RequestOptions) =>
@@ -2,8 +2,6 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
export type ServiceHealth = { healthy: true; version: string; pid: number }
export type ServiceStopResponse = { accepted: boolean }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any }
@@ -12,7 +10,11 @@ export type AgentColor = string
export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -198,6 +200,10 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type TokenUsageInfo = {
input: number
output: number
@@ -2273,10 +2279,6 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
export type HealthGetOutput = ServiceHealth
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = {
+15 -47
View File
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises"
import { readFile, rm } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
@@ -10,7 +10,7 @@ import {
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
import type { ServiceHealth } from "./generated/types.js"
export * from "../service.js"
@@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
return spawnServiceContender(command, args)
return spawnServiceContender(command, args, options.env)
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options, timing)
await terminate(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
await kill(service, options, timing).catch(() => undefined)
await terminate(service.info, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
const info = await read(options.file)
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
function fallback() {
@@ -199,10 +199,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
}
async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) {
try {
process.kill(pid, name)
@@ -230,47 +226,19 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid, timing)) return
if (!(await waitUntilStopped(info.pid, timing))) {
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (await waitUntilStopped(service.info.pid, timing)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = await fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
await rm(options.file ?? fallback(), { force: true })
}
function delay(milliseconds: number) {
+10 -2
View File
@@ -10,8 +10,16 @@ export type ServiceContender = {
const stderrLimit = 8 * 1024
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
export function spawnServiceContender(
command: string,
args: ReadonlyArray<string>,
env?: Readonly<Record<string, string>>,
): ServiceContender {
const child = spawn(command, args, {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, ...env },
})
let error: Error | undefined
let closed = false
let stderr = Buffer.alloc(0)
+2
View File
@@ -28,6 +28,8 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Environment variables added to the inherited service process environment. */
readonly env?: Readonly<Record<string, string>>
/** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
+8 -16
View File
@@ -11,6 +11,8 @@ if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
}
if (mode === "environment")
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
@@ -28,7 +30,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "old") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
@@ -36,17 +38,6 @@ const server = Bun.serve({
port: 0,
async fetch(request) {
const pathname = new URL(request.url).pathname
if (pathname === "/api/service/stop" && mode === "reject-stop") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
setTimeout(shutdown, 25)
return Response.json({ accepted: true })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".health-request", "")
@@ -63,7 +54,7 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
if (mode === "starting" || mode === "graceful")
return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
@@ -81,9 +72,10 @@ await writeFile(
)
await rename(registration + ".tmp", registration)
function shutdown() {
async function shutdown(signal?: NodeJS.Signals) {
if (signal !== undefined) await writeFile(registration + ".signal", signal)
server.stop(true)
process.exit()
}
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown)
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
+23 -3
View File
@@ -59,6 +59,26 @@ test("ensures a missing service with native promises", async () => {
}
})
test("adds configured environment variables with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -126,13 +146,13 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
test("signals the registered service process", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
})
async function setup(mode: string) {
-16
View File
@@ -191,22 +191,6 @@ test("integration connections optionally submit a form answer", async () => {
expect(await requests[3].json()).toEqual({ methodID: "device" })
})
test("health.stop sends exact replacement identity", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ accepted: true })
},
})
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
expect(await request?.json()).toEqual({ instanceID: "instance" })
})
test("MCP resource catalog uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+36 -29
View File
@@ -68,6 +68,28 @@ test("reuses a compatible registered service", async () => {
expect(existing.exitCode).toBe(null)
})
test("adds configured environment variables when starting a service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -143,40 +165,36 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
test("signals an unresponsive registered service process", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const process = spawn(registration, "graceful")
const process = spawn(registration, "hanging")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration }))
await process.exited
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
})
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
test("signals an incompatible service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
const existing = spawn(registration, "old")
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
command: [process.execPath, fixture, registration, "delayed", "10"],
}),
)
const replacement = await Bun.file(registration).json()
await waitForLines(registration + ".stop-attempts", 2)
controller.abort()
await starting.catch(() => undefined)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
expect(await existing.exited).toBe(0)
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("a legacy health response is still replaced", async () => {
@@ -344,17 +362,6 @@ async function waitForFile(file: string) {
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
async function health(url: string) {
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id)
}),
resolve: Effect.fn("Agent.resolve")(function* (id) {
resolve: Effect.fnUntraced(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
}),
+1 -1
View File
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
)
return Service.of({
entries: Effect.fn("Config.entries")(function* () {
entries: Effect.fnUntraced(function* () {
return configs
}),
update,
+1 -1
View File
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
"agents",
migratedAgents,
nativeAgents,
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
diagnostics,
)
@@ -120,9 +120,13 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform
if (method === "values") return statement.values.pipe(Effect.withTracerEnabled(false))
if (method === "get")
return statement.withoutTransform.pipe(
Effect.map((rows) => rows[0]),
Effect.withTracerEnabled(false),
)
return statement.withoutTransform.pipe(Effect.withTracerEnabled(false))
}
private isInTransaction() {
+5 -5
View File
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
@@ -170,7 +170,7 @@ export const make = Effect.gen(function* () {
return result.info
})
const fork = Effect.fn("Job.fork")(function* (
const fork = Effect.fnUntraced(function* (
scope: Scope.Scope,
id: string,
token: object,
@@ -192,7 +192,7 @@ export const make = Effect.gen(function* () {
return snapshot(job)
})
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
@@ -247,7 +247,7 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true }
})
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
yield* SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
@@ -258,7 +258,7 @@ export const make = Effect.gen(function* () {
})
})
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
const block: Interface["block"] = Effect.fnUntraced(function* (input) {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
+1 -1
View File
@@ -65,7 +65,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
return {
+2 -2
View File
@@ -152,14 +152,14 @@ const layer = Layer.effect(
)
})
const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
+1 -1
View File
@@ -39,7 +39,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const list = Effect.fnUntraced(function* (input?: ListInput) {
const rows = yield* db
.select()
.from(PermissionTable)
+47 -13
View File
@@ -1,10 +1,10 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
@@ -23,11 +23,17 @@ import { Tool } from "./tool.js"
import { PluginHooks } from "./plugin/hooks.js"
export interface Interface {
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
}
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
readonly version: string
readonly source?: Plugin.Source
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -38,6 +44,7 @@ const layer = Layer.effect(
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
@@ -56,15 +63,18 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
if (Exit.isSuccess(loaded)) return child
if (Exit.isSuccess(loaded)) return { scope: child } as const
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id,
cause: loaded.cause,
})
return undefined
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>()
for (const definition of definitions) {
@@ -85,26 +95,40 @@ const layer = Layer.effect(
const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version
})
)
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded) {
active.set(definition.id, { plugin: definition, scope: loaded })
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored) {
active.set(definition.id, { plugin: previous.plugin, scope: restored })
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -119,6 +143,7 @@ const layer = Layer.effect(
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -136,7 +161,7 @@ const layer = Layer.effect(
const service = Service.of({
activate,
list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id }))
return inventory
}),
})
host = yield* PluginHost.make(service)
@@ -144,6 +169,15 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
}
}
export const node = makeLocationNode({
service: Service,
layer,
+1 -1
View File
@@ -68,7 +68,7 @@ const layer = Layer.effect(
return { dispose }
})
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
@@ -7,7 +7,7 @@ import { Effect } from "effect"
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode-exclusion",
id: "opencode.mcp.codemode.exclusion",
effect: Effect.fn(function* (ctx) {
yield* ctx.mcp.transform((draft) => {
for (const [, server] of draft.list()) {
+8 -3
View File
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
import { Provider } from "../provider.js"
export const ModelsDevPlugin = define({
id: "opencode.models-dev",
id: "opencode.models.dev",
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
@@ -55,8 +55,13 @@ export const ModelsDevPlugin = define({
})
function environmentNames(provider: ModelsDev.Snapshot) {
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
if (provider.info.id === Provider.ID.azure)
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
// models.dev advertises project, location, and the ADC credentials file path for
// Vertex. Those configure Google auth rather than carrying a key, so only the
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
return [...provider.environment]
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
}
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon-bedrock",
id: "opencode.provider.amazon.bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-ai-gateway")
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
id: "opencode.provider.cloudflare.ai.gateway",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
id: "opencode.provider.cloudflare.workers.ai",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
}) satisfies IntegrationOAuthMethodRegistration
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
id: "opencode.provider.github.copilot",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
}
export const GoogleVertexPlugin = define({
id: "opencode.provider.google-vertex",
id: "opencode.provider.google.vertex",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -71,6 +71,9 @@ export const GoogleVertexPlugin = define({
const project = resolveProject(item.provider.settings ?? {})
const location = String(resolveLocation(item.provider.settings ?? {}))
evt.provider.update(item.provider.id, (provider) => {
// Vertex authenticates through ADC rather than a key credential, so a
// resolvable project is what makes the provider usable.
if (project && provider.activation === "auto") provider.activation = "enabled"
provider.settings = {
...provider.settings,
...(project ? { project } : {}),
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai-compatible",
id: "opencode.provider.openai.compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core",
id: "opencode.provider.sap.ai.core",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.hook(
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
}
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex",
id: "opencode.provider.snowflake.cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
+1 -1
View File
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+45 -13
View File
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -19,12 +19,14 @@ const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -42,10 +44,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
@@ -65,21 +69,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const plugin = yield* load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if (!plugin) continue
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
}
return [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
}
})
const load = Effect.fn("PluginSupervisor.load")(function* (
@@ -89,7 +107,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
@@ -103,7 +121,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
@@ -129,13 +149,20 @@ export const layer = Layer.effect(
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
yield* registry.activate(resolved.plugins, resolved.failures)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
@@ -172,4 +199,9 @@ const nodeDeps = [
PluginInternal.requirements,
] as const
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
function make(id: string, select: (modelID: string) => string | undefined) {
return define({
id: `opencode.system-prompt.${id}`,
id: `opencode.prompt.${id}`,
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
+11
View File
@@ -48,6 +48,17 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
[
"@opencode-ai/ai/providers/google-vertex/responses",
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
],
[
"@opencode-ai/ai/providers/google-vertex/messages",
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
+2 -1
View File
@@ -231,7 +231,8 @@ export const layer = Layer.effect(
http: {
headers: SessionModelHeaders.make(session, app),
},
promptCacheKey: SessionPromptCacheKey.make(session.id),
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
+1 -1
View File
@@ -54,7 +54,7 @@ const layer = Layer.effect(
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
get: Effect.fnUntraced(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
+6 -5
View File
@@ -5,7 +5,8 @@ import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { Model } from "../model.js"
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
input: safe(usage?.nonCachedInputTokens),
@@ -26,10 +27,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero
return Money.USD.make(
(usage.input * cost.input +
(usage.output + usage.reasoning) * cost.output +
usage.cache.read * cost.cache.read +
usage.cache.write * cost.cache.write) /
(usage.input * finite(cost.input) +
(usage.output + usage.reasoning) * finite(cost.output) +
usage.cache.read * finite(cost.cache.read) +
usage.cache.write * finite(cost.cache.write)) /
1_000_000,
)
}
+2 -2
View File
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
@@ -153,7 +153,7 @@ export const layer = (options?: ShellSelect.Options) =>
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
+2 -2
View File
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (
export const scan = Effect.fnUntraced(function* (
command: string,
shell: string,
cwd: string,
@@ -163,7 +163,7 @@ export const scan = Effect.fn("ShellParse.scan")(function* (
return yield* scanLegacy(command, shell, cwd)
})
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
+1 -4
View File
@@ -97,10 +97,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const materialize = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms)
yield* apply(transform.run, api).pipe(
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
)
for (const transform of transforms) yield* apply(transform.run, api)
yield* commit(next)
})
+1 -1
View File
@@ -51,7 +51,7 @@ const layer = Layer.effect(
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
const truncate = Effect.fnUntraced(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result
const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
+1 -1
View File
@@ -50,7 +50,7 @@ const layer = Layer.effect(
const image = yield* Image.Service
type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fn("Tool.normalizeImages")(function* (content: ReadonlyArray<Tool.Content>) {
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
+2 -2
View File
@@ -208,7 +208,7 @@ export const Plugin = {
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
@@ -228,7 +228,7 @@ export const Plugin = {
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
+6 -11
View File
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js"
@@ -30,7 +29,6 @@ export const Plugin = {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const config = yield* Config.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
@@ -97,9 +95,7 @@ export const Plugin = {
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* config.update((draft) => {
draft.websearch = false
})
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
@@ -131,11 +127,7 @@ export const Plugin = {
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* config.update((draft) => {
draft.websearch = {
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
}
})
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
@@ -206,7 +198,10 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const disabled = Config.latest(yield* config.entries(), "websearch") === false
const disabled = yield* websearch.default().pipe(
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
}),
)
+22 -8
View File
@@ -1,9 +1,10 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { State } from "./state.js"
export const ID = WebSearch.ID
@@ -24,6 +25,10 @@ export type Result = WebSearch.Result
export const Response = WebSearch.Response
export type Response = WebSearch.Response
export const ProviderKey = "websearch:provider"
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
export type Selection = typeof Selection.Type
export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
}
@@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
@@ -56,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = {
readonly providers: Map<ID, ProviderImplementation>
selection?: ID | "random" | false
selection?: Selection
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | "random" | false | undefined
set: (selection: ID | "random" | false) => void
get: () => Selection | undefined
set: (selection: Selection) => void
}
}
@@ -71,6 +77,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
@@ -91,12 +98,16 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
if (data.selection === false) return yield* new DisabledError()
if (data.selection === "random") {
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
const decoded = Schema.decodeUnknownOption(Selection)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return data.selection ? data.providers.get(data.selection) : undefined
return selection ? data.providers.get(selection) : undefined
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
@@ -120,6 +131,9 @@ const layer = Layer.effect(
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
@@ -135,5 +149,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
deps: [Bus.node, KV.node],
})
@@ -150,6 +150,16 @@ describe("ConfigNormalize", () => {
})
test("migrates the legacy small model to the title agent", () => {
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
expect(result.encoded.agents).toEqual({
title: {
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
},
})
expect(result.diagnostics).toEqual([])
})
test("merges the legacy small model with the title agent", () => {
const result = normalized({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
+23 -2
View File
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
(yield* plugins.list())
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
.filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")])
}),
),
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
},
status: "active",
tui: true,
})
}),
),
)
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
const inventory = yield* plugins.list()
const ids = inventory.map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
+26 -1
View File
@@ -6,7 +6,7 @@ import { expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect } from "effect"
import { Effect, Tracer } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { isSqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
@@ -49,6 +49,31 @@ test("selects rows through Effect-yieldable query builders", async () => {
)
})
test("suppresses statement spans", async () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
await Effect.runPromise(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }))
yield* db.select().from(users)
}).pipe(
Effect.provideService(Tracer.Tracer, tracer),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
)
expect(spans.map((span) => span.name)).not.toContain("sql.execute")
})
test("commits successful transactions", async () => {
await run(
Effect.gen(function* () {
+10 -2
View File
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).length === 0) break
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([])
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
},
])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) {
+39
View File
@@ -35,6 +35,17 @@ describe("Npm.sanitize", () => {
})
})
describe("Npm.isRegistryPackage", () => {
test("accepts registry packages and rejects unsupported install targets", async () => {
expect(await Npm.isRegistryPackage("plugin")).toBe(true)
expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true)
expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true)
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false)
})
})
describe("Npm.add", () => {
test("resolves cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
@@ -106,3 +117,31 @@ describe("Npm.add", () => {
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
})
describe("Npm.resolve", () => {
test("resolves a TUI entrypoint only when the package is already cached", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(missing.entrypoint).toBeUndefined()
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, {
name: "fixture-plugin",
exports: { ".": "./index.js", "./tui": "./tui.js" },
})
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
await Bun.write(path.join(directory, "tui.js"), "export default {}\n")
const resolved = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
})
})
+48 -13
View File
@@ -89,13 +89,7 @@ describe("Plugin", () => {
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual([
"add:/target",
"remove:/target",
"connect:/target",
"disconnect:/target",
"list:/target",
])
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
}),
)
@@ -138,9 +132,22 @@ describe("Plugin", () => {
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
status: "failed",
error: "failed to resolve",
tui: false,
},
],
)
expect(updates).toBe(3)
yield* plugins.activate([])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(3)
expect(updates).toBe(4)
yield* unsubscribe
}),
)
@@ -160,7 +167,7 @@ describe("Plugin", () => {
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active }])
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
}),
)
@@ -189,12 +196,24 @@ describe("Plugin", () => {
})
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
@@ -229,7 +248,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
@@ -261,7 +288,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
+1
View File
@@ -31,6 +31,7 @@ const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
}),
)
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
tui: true,
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
+42 -2
View File
@@ -421,8 +421,48 @@ describe("ModelsDevPlugin", () => {
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined()
expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined()
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure.cognitive.services")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google.vertex.anthropic")
}),
)
it.effect("advertises only key-bearing Google Vertex environment variables", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: Provider.ID.make("google-vertex"),
name: "Google Vertex",
activation: "auto",
package: Provider.aisdk("@ai-sdk/google-vertex"),
},
environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
models: [],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
}),
),
)
// Vertex authenticates through ADC; project, location, and the credentials
// file path are configuration, not API keys.
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({
methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }],
})
}),
)
@@ -23,6 +23,7 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.succeed(undefined),
})
}
@@ -141,6 +141,52 @@ describe("GoogleVertexPlugin", () => {
),
)
it.effect("enables the provider when a project resolves and leaves it automatic otherwise", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: undefined,
GOOGLE_CLOUD_PROJECT: undefined,
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("auto")
}),
),
)
it.effect("enables the provider when a project resolves from env", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: undefined,
GOOGLE_CLOUD_PROJECT: "adc-project",
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("enabled")
}),
),
)
it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
withEnv(
{
@@ -14,6 +14,7 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
})
@@ -43,10 +43,10 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex")
const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai-compatible"),
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai.compatible"),
)
}),
)
@@ -48,12 +48,12 @@ describe("SystemPromptPlugin", () => {
test("uses granular IDs with a common prefix", () => {
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.system-prompt.openai",
"opencode.system-prompt.google",
"opencode.system-prompt.anthropic",
"opencode.system-prompt.kimi",
"opencode.system-prompt.arcee",
"opencode.system-prompt.meta",
"opencode.prompt.openai",
"opencode.prompt.google",
"opencode.prompt.anthropic",
"opencode.prompt.kimi",
"opencode.prompt.arcee",
"opencode.prompt.meta",
])
})
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Provider } from "@opencode-ai/core/provider"
describe("Provider", () => {
test("loads Vertex native provider entrypoints", async () => {
const packages = [
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
"@opencode-ai/ai/providers/google-vertex/chat",
"@opencode-ai/ai/providers/google-vertex/responses",
"@opencode-ai/ai/providers/google-vertex/messages",
]
for (const specifier of packages) {
const loaded = await Effect.runPromise(Provider.loadPackage(specifier))
expect(loaded.model).toBeFunction()
}
})
})
+23
View File
@@ -197,6 +197,29 @@ test("calculates step cost using the matching context tier", () => {
).toBeCloseTo(0.0002926)
})
test("ignores malformed model cost fields", () => {
const costs = [
{
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(15),
cache: {
read: Money.USDPerMillionTokens.make(0.3),
write: Money.USDPerMillionTokens.make(3.75),
},
},
]
Object.assign(costs[0], { input: {} })
expect(
SessionUsage.calculateCost(costs, {
input: 1_000_000,
output: 100_000,
reasoning: 0,
cache: { read: 0, write: 0 },
}),
).toBe(Money.USD.make(1.5))
})
test("does not apply an ineligible tier without base pricing", () => {
expect(
SessionUsage.calculateCost(
-22
View File
@@ -186,28 +186,6 @@ describe("WebFetchTool helpers", () => {
)
})
test("parses malformed tag prefixes in linear time without a regex prepass", () => {
const small = "<a".repeat(250_000)
const large = "<a".repeat(1_000_000)
const start = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(small)
const smallDuration = Bun.nanoseconds() - start
const next = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(large)
const largeDuration = Bun.nanoseconds() - next
expect(largeDuration).toBeLessThan(smallDuration * 10)
})
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(codeOutput.startsWith("~~~\n")).toBe(true)
})
test("does not confuse source NUL text with buffered code", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
+2 -1
View File
@@ -29,7 +29,7 @@ const webSearchToolNode = makeLocationNode({
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
}),
),
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node],
deps: [Tool.node, Permission.node, WebSearch.node, Form.node],
})
const sessionID = Session.ID.make("ses_websearch_test")
@@ -93,6 +93,7 @@ const websearch = Layer.succeed(
if (selection === false) return yield* new WebSearch.DisabledError()
return selection ? providers.find((provider) => provider.id === selection) : undefined
}),
select: (next) => Effect.sync(() => (selection = next)),
query: (input) =>
Effect.gen(function* () {
queries.push(input)
+27 -1
View File
@@ -3,10 +3,11 @@ import { Effect, Exit, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const register = (id: string) =>
Effect.gen(function* () {
@@ -80,6 +81,31 @@ describe("WebSearch", () => {
}),
)
it.effect("persists the selected provider in KV", () =>
Effect.gen(function* () {
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* websearch.select(parallel.providerID)
expect(yield* kv.get(WebSearch.ProviderKey)).toBe(parallel.providerID)
expect((yield* websearch.query({ query: "remembered" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("keeps config transforms above the persisted selection", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.providerID)
yield* websearch.transform((draft) => draft.default.set(exa.providerID))
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(exa.providerID)
}),
)
it.effect("chooses a registered provider for random selection", () =>
Effect.gen(function* () {
yield* register("exa")
+1
View File
@@ -37,6 +37,7 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly tui?: boolean
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
+1
View File
@@ -67,6 +67,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
tui: plugin.tui,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
+1
View File
@@ -38,6 +38,7 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly tui?: boolean
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
-72
View File
@@ -48,58 +48,6 @@
"summary": "Check server health"
}
},
"/api/service/stop": {
"post": {
"tags": ["health"],
"operationId": "v2.health.stop",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServiceStopResponse",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopResponse"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Request graceful shutdown of one exact managed server instance.",
"summary": "Stop the managed server",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopRequest"
}
}
},
"required": true
}
}
},
"/api/server": {
"get": {
"tags": ["server"],
@@ -9783,26 +9731,6 @@
"required": ["_tag", "message"],
"additionalProperties": false
},
"ServiceStopRequest": {
"type": "object",
"properties": {
"instanceID": {
"type": "string"
}
},
"required": ["instanceID"],
"additionalProperties": false
},
"ServiceStopResponse": {
"type": "object",
"properties": {
"accepted": {
"type": "boolean"
}
},
"required": ["accepted"],
"additionalProperties": false
},
"Union_1": {
"anyOf": [
{
-22
View File
@@ -9,16 +9,6 @@ export namespace ServiceStatus {
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "ServiceHealth" })
export type Health = typeof Health.Type
export const StopRequest = Schema.Struct({
instanceID: Schema.String,
}).annotate({ identifier: "ServiceStopRequest" })
export type StopRequest = typeof StopRequest.Type
export const StopResponse = Schema.Struct({
accepted: Schema.Boolean,
}).annotate({ identifier: "ServiceStopResponse" })
export type StopResponse = typeof StopResponse.Type
}
export const HealthGroup = HttpApiGroup.make("server.health")
@@ -33,16 +23,4 @@ export const HealthGroup = HttpApiGroup.make("server.health")
}),
),
)
.add(
HttpApiEndpoint.post("health.stop", "/api/service/stop", {
payload: ServiceStatus.StopRequest,
success: ServiceStatus.StopResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.stop",
summary: "Stop the managed server",
description: "Request graceful shutdown of one exact managed server instance.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "health" }))
+1 -1
View File
@@ -15,7 +15,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
OpenApi.annotations({
identifier: "v2.plugin.list",
summary: "List plugins",
description: "Retrieve currently loaded plugins.",
description: "Retrieve enabled server plugins and their current status.",
}),
),
)
+25 -4
View File
@@ -2,14 +2,35 @@ export * as Plugin from "./plugin.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
}).annotate({ identifier: "Plugin.Info" })
export const Source = Schema.Union([
Schema.Struct({ type: Schema.Literal("builtin") }),
Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }),
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
Schema.Struct({ type: Schema.Literal("sdk") }),
]).annotate({ identifier: "Plugin.Source" })
export type Source = typeof Source.Type
export const Info = Schema.Union([
Schema.Struct({
id: ID,
source: Source,
status: Schema.Literal("active"),
tui: Schema.Boolean,
}),
Schema.Struct({
id: ID.pipe(optional),
source: Source,
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
}),
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
const Added = ephemeral({
type: "plugin.added",
+11 -13
View File
@@ -4,17 +4,15 @@ import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers
.handle("health.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
healthy: true as const,
version: info.app.version ?? "unknown",
// Runtimes without OS process identity (workerd) report 0.
pid: process.pid ?? 0,
}
}),
)
.handle("health.stop", () => Effect.succeed({ accepted: false })),
handlers.handle("health.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
healthy: true as const,
version: info.app.version ?? "unknown",
// Runtimes without OS process identity (workerd) report 0.
pid: process.pid ?? 0,
}
}),
),
)
+6 -46
View File
@@ -1,12 +1,10 @@
export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { NodeHttpServer } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { randomUUID } from "node:crypto"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
@@ -18,7 +16,6 @@ import { Status } from "./service-status"
import type { ServerOptions } from "./options"
export interface Lifecycle<E = never, R = never> {
readonly instanceID: string
readonly onListen: (
address: HttpServer.Address,
shutdown: Effect.Effect<void>,
@@ -51,16 +48,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const hostname = options.hostname ?? "127.0.0.1"
const port = Option.fromNullishOr(options.port)
const shutdown = yield* Deferred.make<void>()
const status = yield* Status.make({
instanceID: lifecycle?.instanceID ?? randomUUID(),
managed: lifecycle !== undefined,
})
const status = yield* Status.make()
const bound = yield* listen({ hostname, port })
const application = yield* Ref.make(Option.none<App>())
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
dispatch(password, status, application, options.app?.version ?? "unknown").pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
),
errorResponseLogger,
@@ -163,22 +157,15 @@ function dispatch(
password: string,
status: Status.Interface,
application: Ref.Ref<Option.Option<App>>,
shutdown: Deferred.Deferred<void>,
version: string,
): App {
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
const lifecycle =
request.method === "GET" && url.pathname === "/api/health"
? "health"
: request.method === "POST" && url.pathname === "/api/service/stop"
? "stop"
: undefined
if (lifecycle !== undefined) {
if (request.method === "GET" && url.pathname === "/api/health") {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
return yield* healthResponse(status, version)
}
const state = yield* status.current
const app = yield* Ref.get(application)
@@ -196,33 +183,6 @@ function unauthorized() {
})
}
const control = Effect.fnUntraced(function* (
request: HttpServerRequest.HttpServerRequest,
route: "health" | "stop",
status: Status.Interface,
stop: () => void,
version: string,
) {
if (route === "health") return yield* healthResponse(status, version)
const body = yield* request.json.pipe(Effect.option)
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
const accepted = yield* status.requestStop(input.value)
if (accepted) {
const response = NodeHttpServerRequest.toServerResponse(request)
yield* Effect.sync(() => {
const complete = () => {
response.off("finish", complete)
response.off("close", complete)
stop()
}
response.once("finish", complete)
response.once("close", complete)
})
}
return HttpServerResponse.jsonUnsafe({ accepted })
})
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
const state = yield* status.current
return HttpServerResponse.jsonUnsafe(
+1 -11
View File
@@ -1,6 +1,5 @@
export * as Status from "./service-status"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, Ref } from "effect"
export type State =
@@ -14,14 +13,9 @@ export interface Interface {
readonly ready: Effect.Effect<void>
readonly fail: Effect.Effect<void>
readonly beginStopping: Effect.Effect<void>
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
}
export const make = Effect.fnUntraced(function* (options: {
readonly instanceID: string
readonly managed: boolean
readonly initial?: State
}) {
export const make = Effect.fnUntraced(function* (options: { readonly initial?: State } = {}) {
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
const beginStopping = Ref.update(current, (status) =>
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
@@ -32,9 +26,5 @@ export const make = Effect.fnUntraced(function* (options: {
ready: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "ready" } satisfies State) : status)),
fail: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "failed" } satisfies State) : status)),
beginStopping,
requestStop: (request) => {
if (!options.managed || request.instanceID !== options.instanceID) return Effect.succeed(false)
return beginStopping.pipe(Effect.as(true))
},
} satisfies Interface
})
+4 -15
View File
@@ -5,7 +5,7 @@ import { Status } from "../src/service-status"
it.effect("moves from starting to ready", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: false })
const status = yield* Status.make()
expect(yield* status.current).toEqual({ type: "starting" })
yield* status.ready
expect(yield* status.current).toEqual({ type: "ready" })
@@ -14,7 +14,7 @@ it.effect("moves from starting to ready", () =>
it.effect("keeps a startup failure until shutdown", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
const status = yield* Status.make()
yield* status.fail
yield* status.ready
yield* status.fail
@@ -22,24 +22,13 @@ it.effect("keeps a startup failure until shutdown", () =>
}),
)
it.effect("stops only the addressed managed instance", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
expect(yield* status.requestStop({ instanceID: "other" })).toBe(false)
expect(yield* status.current).toEqual({ type: "starting" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
expect(yield* status.current).toEqual({ type: "stopping" })
}),
)
it.effect("keeps stopping after shutdown begins", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
const status = yield* Status.make()
yield* status.beginStopping
expect(yield* status.current).toEqual({ type: "stopping" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
yield* status.beginStopping
expect(yield* status.current).toEqual({ type: "stopping" })
}),
)
+1
View File
@@ -24,6 +24,7 @@
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
"./theme/discovery": "./src/theme/discovery.ts",
"./plugin/discovery": "./src/plugin/discovery.ts",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
"./attention": "./src/attention.ts",
@@ -1,16 +1,14 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
@@ -20,19 +18,21 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
const [height, setHeight] = createSignal(1)
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
onMount(() => dialog.setSize("large"))
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
if (!scroll) return
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
setHeight(next)
setScrollable(scroll.scrollHeight > next)
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
@@ -61,15 +61,15 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<box paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
@@ -77,7 +77,6 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
esc
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
@@ -96,7 +95,7 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>

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