Compare commits

...
Author SHA1 Message Date
Aiden Cline de4027c974 fix(core): centralize native provider mapping 2026-08-19 10:46:45 -05:00
Shoubhit Dash ace822308f refactor(core): move compaction config into state (#43442) 2026-08-19 20:43:01 +05:30
opencode-agent[bot]andnexxeln 728053b645 fix(tui): avoid retrying broken plugin setup (#43441)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 20:41:13 +05:30
opencode-agent[bot]andrekram1-node bfe9917ee7 fix(core): lower AI SDK system updates (#43440)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 10:03:03 -05:00
opencode-agent[bot]andrekram1-node 1556b74082 fix(core): preserve Vertex billing labels (#43449)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 09:57:47 -05:00
Dax 7700faad81 feat(cli): capture CPU profiles with SIGPROF (#43446) 2026-08-19 10:45:05 -04:00
Kit Langton 241a88a5d9 fix(tui): open subagent panel from footer (#43325) 2026-08-19 10:33:44 -04:00
Major Hayden 3edcb3ca2b fix(core): route Vertex Gemini through native provider (#43433)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-19 08:59:11 -05:00
opencode-agent[bot]andnexxeln dcc7de2e47 test(app): stabilize reconnect offset timing (#43438)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 19:27:34 +05:30
Shoubhit Dash c40c306170 refactor(core): move shell config into state (#43430) 2026-08-19 19:23:22 +05:30
Shoubhit Dash a207253242 fix(core): await snapshot state readiness (#43435) 2026-08-19 19:23:10 +05:30
Shoubhit Dash 1e867c228a refactor(core): move snapshot config into state (#43425) 2026-08-19 18:34:54 +05:30
Shoubhit Dash 8a402d3f03 refactor(core): move tool output config into state (#43422) 2026-08-19 18:25:22 +05:30
opencode-agent[bot]andLuke Parker 33567c5792 fix(desktop): connect wildcard service through loopback (#43171)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-19 14:39:54 +10:00
Aiden Cline daf3f9ed08 feat(ai): support Responses tool controls (#43329) 2026-08-18 23:33:04 -05:00
opencode-agent[bot]andrekram1-node d5bf8799c0 fix(cli): keep run event stream alive (#43348)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-18 23:31:26 -05:00
Dax f6f64d7ece feat(cli): manage plugin packages (#43283) 2026-08-19 00:06:15 -04:00
74 changed files with 1950 additions and 454 deletions
+22 -1
View File
@@ -141,6 +141,11 @@ export const Tool = Schema.Struct({
export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({
type: Schema.tag("allowed_tools"),
mode: Schema.Literals(["auto", "none", "required"]),
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
}),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -170,6 +175,8 @@ export const coreFields = {
}),
),
max_output_tokens: Schema.optional(Schema.Number),
max_tool_calls: Schema.optional(Schema.Int),
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
}
@@ -578,10 +585,22 @@ const lowerOptions = (request: LLMRequest) => {
: {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
type: "allowed_tools" as const,
mode: allowed.mode,
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
extension: Extension,
@@ -601,7 +620,9 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -121,7 +121,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
} satisfies OpenAIResponsesBody
})
@@ -1,4 +1,4 @@
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
export const ResponseIncludables = [
@@ -11,7 +11,7 @@ export const ResponseIncludables = [
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number]
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
@@ -19,52 +19,54 @@ export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const TRUNCATIONS = new Set<string>(Truncations)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
const isTruncation = (value: unknown): value is Truncation => typeof value === "string" && TRUNCATIONS.has(value)
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export interface Resolved {
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
readonly serviceTier?: ServiceTier
readonly truncation?: Truncation
export const AllowedTools = Schema.Struct({
toolNames: Schema.Array(Schema.String),
mode: Schema.optional(Schema.Literals(["auto", "none", "required"])),
})
export type AllowedTools = typeof AllowedTools.Type
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
const include = Array.isArray(input?.include)
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
: []
const reasoningSummary = input?.reasoningSummary
const input = Option.getOrUndefined(
decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]),
)
if (!input) return {}
return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
? reasoningSummary
...input,
include: input.include?.length ? input.include : undefined,
allowedTools:
input.allowedTools && input.allowedTools.toolNames.length > 0
? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" }
: undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
truncation: isTruncation(input?.truncation) ? input.truncation : undefined,
}
}
@@ -1,17 +1,7 @@
import type { ResponseIncludable, ServiceTier, Truncation } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions } from "../schema/index.js"
export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
readonly truncation?: Truncation
}
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
@@ -19,6 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -75,6 +76,7 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
export const baseten = define(profiles.baseten)
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
@@ -123,14 +123,36 @@ describe("Open Responses-compatible route", () => {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true, truncation: "auto" } },
providerOptions: {
openresponses: {
reasoningEffort: "low",
store: true,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
},
},
}).model("example-model")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Think.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
mode: "auto",
tools: [{ type: "function", name: "lookup" }],
},
max_tool_calls: 2,
parallel_tool_calls: false,
})
}),
)
@@ -246,11 +246,7 @@ describe("OpenAI Responses route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.system("Operator update."),
Message.assistant("After."),
],
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
@@ -1278,12 +1274,20 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
],
toolChoice: "none",
providerOptions: {
openai: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
parallelToolCalls: false,
},
},
}),
@@ -1295,6 +1299,16 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: "read" },
{ type: "function", name: "grep" },
],
})
expect(prepared.body.max_tool_calls).toBe(4)
expect(prepared.body.parallel_tool_calls).toBe(false)
}),
)
@@ -1320,20 +1334,17 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("filters unknown includable values out of the include array", () =>
it.effect("passes forward-compatible includable values through", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}),
)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"])
}),
)
@@ -1347,13 +1358,13 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("treats an all-invalid include as no include at all", () =>
it.effect("passes an unknown includable value through", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
expect(prepared.body.include).toBeUndefined()
expect(prepared.body.include).toEqual(["bogus.thing"])
}),
)
@@ -62,13 +62,16 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const animation = controlledAnimationFrames(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
route.append(viewport)
document.body.append(route)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
@@ -83,20 +86,23 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
try {
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
animation.run(16)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
instance.scrollOffset = 79_400
animation.run(32)
animation.run(48)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
expect(animation.pending()).toBe(0)
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
})
test.each([
@@ -235,3 +241,29 @@ function controlledMutations(targetWindow: Window) {
},
}
}
function controlledAnimationFrames(targetWindow: Window) {
let time = 0
let id = 0
const callbacks = new Map<number, FrameRequestCallback>()
Object.defineProperty(targetWindow.performance, "now", { value: () => time })
Object.defineProperty(targetWindow, "requestAnimationFrame", {
value: (callback: FrameRequestCallback) => {
id += 1
callbacks.set(id, callback)
return id
},
})
Object.defineProperty(targetWindow, "cancelAnimationFrame", {
value: (frame: number) => callbacks.delete(frame),
})
return {
run(at: number) {
time = at
const pending = [...callbacks.values()]
callbacks.clear()
pending.forEach((callback) => callback(at))
},
pending: () => callbacks.size,
}
}
+25 -4
View File
@@ -1,6 +1,5 @@
import { Argument, Command, Flag } from "effect/unstable/cli"
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -160,7 +159,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",
@@ -321,4 +342,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
})
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
export const Commands = Root
-12
View File
@@ -1,12 +0,0 @@
export * as GlobalFlags from "./global-flags"
import { Flag, GlobalFlag } from "effect/unstable/cli"
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
flag: Flag.string("cpu-profile").pipe(
Flag.withDescription("Write a CPU profile to this path when the process stops"),
Flag.optional,
),
})
export const all = [CpuProfile] as const
@@ -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),
)
}
@@ -5,24 +5,69 @@ 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) => name(a).localeCompare(name(b)))
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(name).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
@@ -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)
}
+28 -2
View File
@@ -1,10 +1,36 @@
export * as CpuProfile from "./cpu-profile"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Queue } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
export const listen = Effect.gen(function* () {
const global = yield* Global.Service
if (process.platform === "win32") return
const signals = yield* Queue.dropping<void>(1)
yield* Effect.acquireRelease(
Effect.sync(() => {
const handler = () => Queue.offerUnsafe(signals, undefined)
process.on("SIGPROF", handler)
return handler
}),
(handler) => Effect.sync(() => process.off("SIGPROF", handler)),
)
yield* Effect.gen(function* () {
yield* Queue.take(signals)
const file = path.join(
global.log,
`cpu-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.cpuprofile`,
)
yield* run(file, Effect.sleep("10 seconds")).pipe(
Effect.catchCause((cause) => Effect.logError("Failed to capture CPU profile", { path: file, cause })),
)
yield* Queue.poll(signals)
}).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true }))
})
function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
+2 -19
View File
@@ -1,13 +1,10 @@
import { Effect, FileSystem, Option, Scope } from "effect"
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { GlobalFlags } from "../commands/global-flags"
import { CpuProfile } from "../cpu-profile"
import path from "node:path"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -90,21 +87,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
Command.withHandler((input) =>
Effect.gen(function* () {
const module = yield* Effect.promise(handler.load)
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
if (!cpuProfile) return yield* module.default(input)
const target = path.resolve(cpuProfile)
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = target
return yield* (
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}),
),
)
return yield* module.default(input)
}),
),
)
+4
View File
@@ -13,6 +13,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -38,6 +39,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 +62,7 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
yield* CpuProfile.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
+7 -1
View File
@@ -80,7 +80,13 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
}
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
// Bun's default five-minute deadline terminates the event stream used by long-running sessions.
fetch: ((request: RequestInfo | URL, init?: RequestInit) =>
fetch(request, { ...init, timeout: false } as BunFetchRequestInit)) as typeof fetch,
})
const explicit = parseRunModel(input.model)
const target = await resolveSessionTarget({
client,
@@ -110,7 +110,6 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
...selfCommand(),
"serve",
"--service",
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
],
}
})
+18
View File
@@ -0,0 +1,18 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { CpuProfile } from "../src/cpu-profile"
test("subscribes and unsubscribes SIGPROF with the CLI scope", async () => {
const listeners = process.listenerCount("SIGPROF")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* CpuProfile.listen
expect(process.listenerCount("SIGPROF")).toBe(listeners + (process.platform === "win32" ? 0 : 1))
}),
).pipe(Effect.provideService(Global.Service, Global.make()), Effect.provide(NodeFileSystem.layer)),
)
expect(process.listenerCount("SIGPROF")).toBe(listeners)
})
+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}`
}
})
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import { EOL } from "node:os"
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)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
expect(
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
+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}`
}
})
-23
View File
@@ -19,29 +19,6 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("managed service forwards the CPU profile path to the server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
const profile = path.join(root, "server.cpuprofile")
try {
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = profile
try {
const options = await Effect.runPromise(
ServiceConfig.options().pipe(
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
} finally {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+77 -1
View File
@@ -8,17 +8,30 @@ export interface Mapping {
readonly settings: Readonly<Record<string, unknown>>
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly auth?: "none"
}
export interface MapInput {
readonly packageName: string | undefined
readonly settings: Readonly<Record<string, unknown>>
readonly modelID: string
readonly providerID: string
}
export function map(input: MapInput): Mapping | undefined {
const baseSettings = mapBaseSettings(input.settings)
switch (input.packageName) {
case "@ai-sdk/anthropic":
return {
package: "@opencode-ai/ai/providers/anthropic",
auth: "none",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
...mapAnthropicOptions(input.settings),
},
}
case "@ai-sdk/amazon-bedrock":
return {
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -51,6 +64,22 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex":
return {
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
@@ -72,6 +101,37 @@ export function map(input: MapInput): Mapping | undefined {
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
auth: "none",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapProviderOptions(input.settings, "openai", [
"apiKey",
"baseURL",
"organization",
"project",
"queryParams",
]),
},
}
case "@ai-sdk/openai-compatible":
if (typeof input.settings.baseURL !== "string") return
return {
package: "@opencode-ai/ai/providers/openai-compatible",
auth: "none",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapProviderOptions(input.settings, "openai", ["apiKey", "baseURL"]),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -86,6 +146,20 @@ export function map(input: MapInput): Mapping | undefined {
}
}
function mapAnthropicOptions(settings: Readonly<Record<string, unknown>>) {
return mapProviderOptions(settings, "anthropic", ["apiKey", "authToken", "baseURL"])
}
function mapProviderOptions(
settings: Readonly<Record<string, unknown>>,
key: string,
excluded: ReadonlyArray<string>,
) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
if (Object.keys(options).length === 0) return {}
return { providerOptions: { [key]: options } }
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
@@ -229,7 +303,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -240,9 +314,11 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(isStringRecord(settings.labels) ? { labels: settings.labels } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { gemini: options } }
+14 -1
View File
@@ -460,7 +460,20 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
switch (input.role) {
case "system":
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
// The initial privileged prompt lives in `request.system` and is prepended above. A system message here is a
// chronological instruction update, but opaque AI SDK providers do not uniformly allow the system role after
// conversation history, so preserve its position using the safe wrapped-user fallback.
return [
{
role: "user",
content: [
{
type: "text",
text: ProviderShared.wrapSystemUpdate(input.content.filter((part) => part.type === "text")),
},
],
},
]
case "user":
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
+11 -28
View File
@@ -8,10 +8,8 @@ import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config.js"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info
export type Info = Command.Info
@@ -53,16 +51,15 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
@@ -109,11 +106,9 @@ export const layer = (options?: ShellSelect.Options) =>
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
location,
processes,
shell: options,
bin: global.bin,
shell,
})
const prompt = (yield* mcp.prompts()).find(
@@ -163,11 +158,9 @@ function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
@@ -197,20 +190,14 @@ const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
readonly shell: ShellSelect.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -267,12 +254,8 @@ const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
@@ -0,0 +1,37 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
export const Plugin = define({
id: "opencode.config.compaction",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(compaction.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined
? {}
: { tokens: entry.info.compaction.keep.tokens }),
})
}
})
}),
})
+29
View File
@@ -0,0 +1,29 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
export const Plugin = define({
id: "opencode.config.shell",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(shell.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* shell.transform((draft) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) draft.configure(configured)
})
}),
})
@@ -0,0 +1,30 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
export const Plugin = define({
id: "opencode.config.snapshot",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(snapshot.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* snapshot.transform((draft) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
draft.configure(configured)
})
}),
})
@@ -0,0 +1,33 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
export const Plugin = define({
id: "opencode.config.tool-output",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(output.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
draft.configure({
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
})
})
}),
})
+2
View File
@@ -29,6 +29,7 @@ import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
@@ -71,6 +72,7 @@ const locationServiceNodes = [
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
Pty.node,
Shell.node,
Skill.node,
+6 -85
View File
@@ -2,13 +2,7 @@ export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk.js"
@@ -83,47 +77,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelResolver") {}
const apiKey = (model: Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return undefined
}
const withDefaults = (model: Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
const packageName = Provider.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = Provider.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
return undefined
}
export const withVariant = (
model: Info,
variantID: VariantID | undefined,
@@ -170,37 +123,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
) {
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
@@ -239,6 +169,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
route:
mapping?.auth === "none" && credential === undefined && !hasConfiguredAuth(resolved)
? runtime.route.with({ auth: Auth.none })
: runtime.route,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
@@ -267,19 +201,6 @@ function validateProviderVariables(
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
+24
View File
@@ -13,6 +13,7 @@ import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
@@ -20,7 +21,10 @@ import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
@@ -44,8 +48,11 @@ import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
import { WebSearch } from "../websearch.js"
import { Ripgrep } from "../ripgrep.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionInstructions } from "../session/instructions.js"
import { Shell } from "../shell.js"
import { ShellSelect } from "../shell/select.js"
import { Snapshot } from "../snapshot.js"
import { Skill } from "../skill.js"
import { SkillDiscovery } from "../skill/discovery.js"
import { Watcher } from "../filesystem/watcher.js"
@@ -60,6 +67,7 @@ import { ShellTool } from "../tool/plugin/shell.js"
import { SkillTool } from "../tool/plugin/skill.js"
import { SubagentTool } from "../tool/plugin/subagent.js"
import { Tool } from "../tool.js"
import { ToolOutput } from "../tool-output.js"
import { WebFetchTool } from "../tool/plugin/webfetch.js"
import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
@@ -110,11 +118,15 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const compaction = yield* SessionCompaction.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const snapshot = yield* Snapshot.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const toolOutput = yield* ToolOutput.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
@@ -149,11 +161,15 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionCompaction.Service, compaction),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(ShellSelect.Service, shellSelect),
Context.make(Snapshot.Service, snapshot),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(ToolOutput.Service, toolOutput),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
@@ -195,11 +211,15 @@ export const requirements = LayerNode.group([
Reference.node,
WebSearch.node,
Ripgrep.node,
SessionCompaction.node,
SessionInstructions.node,
Shell.node,
ShellSelect.node,
Snapshot.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
ToolOutput.node,
Watcher.node,
WellKnown.node,
])
@@ -238,8 +258,12 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigCompactionPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigImagePlugin.Plugin,
ConfigShellPlugin.Plugin,
ConfigSnapshotPlugin.Plugin,
ConfigToolOutputPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
@@ -190,13 +190,6 @@ export const OpenAIPlugin = define({
})
yield* load()
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
})
}
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
@@ -236,6 +236,24 @@ problem belongs to the client, the shared server, or one project.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
### CPU profiles
On Linux and macOS, send `SIGPROF` to a running OpenCode process to capture its
CPU activity. Get the background server PID from the health endpoint, then send
the signal:
```sh
opencode2 api get /api/health
kill -SIGPROF <pid>
```
One signal starts a ten-second profile and stops it automatically. OpenCode
writes the result to its log directory as
`cpu-<pid>-<timestamp>.cpuprofile` and logs the complete path. Additional
`SIGPROF` signals are ignored while a profile is active. Signal-triggered CPU
profiles are unavailable on Windows. There is no CPU profile CLI flag or
environment variable.
See the [full troubleshooting guide](https://opencode.ai/v2/docs/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.
+8 -16
View File
@@ -4,12 +4,10 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode-ai/schema/pty"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { PtyID } from "./pty/schema.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
import { lazy } from "./util/lazy.js"
const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -90,14 +88,13 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
@@ -167,8 +164,7 @@ export const layer = (options?: ShellSelect.Options) =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const command = input.command || (yield* shell.preferred())
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -317,12 +313,8 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [Bus.node, Location.node, ShellSelect.node],
})
+20 -10
View File
@@ -628,7 +628,11 @@ const layer = Layer.effect(
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const command = yield* commands.get(input.command)
if (!command)
return yield* new Command.NotFoundError({
@@ -667,6 +671,8 @@ const layer = Layer.effect(
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* shell
.create({
@@ -905,19 +911,23 @@ const layer = Layer.effect(
const session = yield* result.get(input.sessionID)
if ((yield* execution.active).has(input.sessionID))
return yield* new BusyError({ sessionID: input.sessionID })
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* SessionRevert.clear(session).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
}),
+28 -25
View File
@@ -3,9 +3,7 @@ export * as SessionCompaction from "./compaction.js"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
@@ -24,6 +22,7 @@ import type { Info, Ref } from "../model.js"
import { SessionUsage } from "./usage.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -61,10 +60,14 @@ Rules:
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
type Settings = {
readonly auto: boolean
readonly buffer: number
readonly tokens: number
export type Settings = {
auto: boolean
buffer: number
tokens: number
}
export type Draft = {
configure: (settings: Partial<Settings>) => void
}
type Dependencies = {
@@ -74,7 +77,6 @@ type Dependencies = {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
@@ -111,7 +113,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
export interface Interface extends State.Transformable<Draft> {
readonly required: (input: RequiredInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
@@ -165,17 +167,6 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const settings = (documents: readonly Entry[]) => {
const configured = documents
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
}
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
@@ -240,7 +231,17 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
}
const make = (dependencies: Dependencies) => {
const config = dependencies.config
const state = State.create<Settings, Draft>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
draft: (draft) => ({
configure: (settings) => {
if (settings.auto !== undefined) draft.auto = settings.auto
if (settings.buffer !== undefined) draft.buffer = settings.buffer
if (settings.tokens !== undefined) draft.tokens = settings.tokens
},
}),
})
const failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
@@ -350,7 +351,7 @@ const make = (dependencies: Dependencies) => {
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, config.tokens)
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
@@ -368,6 +369,7 @@ const make = (dependencies: Dependencies) => {
})
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -388,7 +390,7 @@ const make = (dependencies: Dependencies) => {
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
@@ -419,6 +421,8 @@ const make = (dependencies: Dependencies) => {
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
required,
compact,
compactManual,
@@ -430,16 +434,15 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
return make({ bus, llm, models, app, hooks })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
})
+4
View File
@@ -34,6 +34,7 @@ import { toSessionError } from "../to-session-error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
@@ -114,6 +115,7 @@ const layer = Layer.effect(
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation starts once input is visible and must not delay model execution.
@@ -135,6 +137,7 @@ const layer = Layer.effect(
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
@@ -646,6 +649,7 @@ export const node = makeLocationNode({
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
PluginSupervisor.node,
SessionTitle.node,
Snapshot.node,
ToolOutput.node,
+17 -27
View File
@@ -7,7 +7,6 @@ import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { AppProcess } from "@opencode-ai/util/process"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
import { Environment } from "./environment/index.js"
import { Location } from "./location.js"
@@ -68,14 +67,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
@@ -146,12 +145,7 @@ export const layer = (options?: ShellSelect.Options) =>
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
@@ -196,7 +190,7 @@ export const layer = (options?: ShellSelect.Options) =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
shell: yield* shell.preferred(),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
@@ -353,20 +347,16 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [
Bus.node,
Location.node,
Config.node,
Global.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [
Bus.node,
Location.node,
Global.node,
ShellSelect.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
+46 -1
View File
@@ -3,8 +3,11 @@ export * as ShellSelect from "./select.js"
import path from "path"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { State } from "../state.js"
import { which } from "../util/which.js"
const META: Record<string, { deny?: boolean; login?: boolean; ps?: boolean }> = {
@@ -30,6 +33,20 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
type Data = {
shell?: string
}
export type Draft = {
configure: (shell: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly preferred: () => Effect.Effect<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
@@ -181,3 +198,31 @@ export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
}
const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const state = State.create<Data, Draft>({
name: "shell-select",
initial: () => ({}),
draft: (draft) => ({
configure: (shell) => {
draft.shell = shell
},
}),
})
return Service.of({
transform: state.transform,
reload: state.reload,
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
})
}),
)
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
export const node = configured()
+22 -11
View File
@@ -3,7 +3,6 @@ export * as Snapshot from "./snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
import { Config } from "./config.js"
import { File } from "./file.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
@@ -12,6 +11,7 @@ import { Location } from "./location.js"
import { AbsolutePath, RelativePath } from "./schema.js"
import { ID } from "@opencode-ai/schema/snapshot"
import { Hash } from "@opencode-ai/util/hash"
import { State } from "./state.js"
export { ID }
@@ -36,7 +36,11 @@ export interface RestoreInput {
readonly files: ReadonlyMap<RelativePath, ID>
}
export interface Interface {
export type Draft = {
configure: (enabled: boolean) => void
}
export interface Interface extends State.Transformable<Draft> {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
@@ -68,12 +72,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sn
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const location = yield* Location.Service
const lifetime = yield* Scope.Scope
const state = State.create<{ enabled: boolean }, Draft>({
name: "snapshot",
initial: () => ({ enabled: true }),
draft: (draft) => ({
configure: (enabled) => {
draft.enabled = enabled
},
}),
})
// Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization.
const repositoryFiber = yield* Effect.cached(
Effect.gen(function* () {
@@ -100,13 +112,10 @@ const layer = Layer.effect(
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const enabled = () => location.vcs?.type === "git" && state.get().enabled
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
if (!enabled()) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository
return ID.make(
@@ -170,26 +179,28 @@ const layer = Layer.effect(
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
return Service.of({ capture, files, diff, restore })
return Service.of({ transform: state.transform, reload: state.reload, capture, files, diff, restore })
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
deps: [FSUtil.node, Git.node, Global.node, Location.node],
})
export const noopLayer = Layer.succeed(
Service,
Service.of({
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
capture: () => Effect.succeed(undefined),
files: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
+32 -11
View File
@@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Identifier } from "./id/id.js"
import { State } from "./state.js"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 // 50 KiB
@@ -16,7 +16,16 @@ export const DIRECTORY = "tool-output"
type Result = Tool.Result
export interface Interface {
type Limits = {
maxLines: number
maxBytes: number
}
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -46,31 +55,38 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const state = State.create<Limits, Draft>({
name: "tool-output",
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
draft: (draft) => ({
configure: (limits) => {
if (limits.maxLines !== undefined) draft.maxLines = limits.maxLines
if (limits.maxBytes !== undefined) draft.maxBytes = limits.maxBytes
},
}),
})
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 ?? [])
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? MAX_LINES
const maxBytes = configured?.max_bytes ?? MAX_BYTES
const limits = state.get()
const lines = text.split("\n")
if (text.endsWith("\n")) lines.pop()
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes)
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = []
let bytes = 0
let hitBytes = false
for (const line of lines.slice(0, maxLines)) {
for (const line of lines.slice(0, limits.maxLines)) {
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
if (bytes + size > limits.maxBytes) {
hitBytes = true
break
}
@@ -113,7 +129,12 @@ const layer = Layer.effect(
}
})
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
return Service.of({
transform: state.transform,
reload: state.reload,
truncate,
cleanup: () => cleanup(fs, directory),
})
}),
)
@@ -137,5 +158,5 @@ const cleanupNode = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
deps: [FSUtil.node, Global.node, cleanupNode],
})
+115 -1
View File
@@ -2,9 +2,94 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
AISDKNative.map({ packageName, settings, modelID })
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
describe("AISDKNative", () => {
test("maps OpenAI-family packages and request options to native providers", () => {
expect(
map("@ai-sdk/openai", {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
organization: "org",
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
}),
).toEqual({
package: "@opencode-ai/ai/providers/openai",
auth: "none",
settings: {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
organization: "org",
providerOptions: {
openai: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
},
},
},
})
expect(map("@ai-sdk/openai-compatible", { baseURL: "https://example.com/v1", reasoningEffort: "high" })).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
auth: "none",
settings: {
baseURL: "https://example.com/v1",
provider: "test-provider",
providerOptions: { openai: { reasoningEffort: "high" } },
},
})
})
test("maps Anthropic settings and request options to the native provider", () => {
expect(
map("@ai-sdk/anthropic", {
authToken: "token",
baseURL: "https://anthropic.example/v1",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/anthropic",
auth: "none",
settings: {
authToken: "token",
baseURL: "https://anthropic.example/v1",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
},
})
})
test("maps Google Vertex settings to the native provider", () => {
expect(
map("@ai-sdk/google-vertex", {
project: "project",
location: "us-central1",
labels: { environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
project: "project",
location: "us-central1",
providerOptions: {
gemini: { labels: { environment: "test" }, thinkingConfig: { thinkingLevel: "high" } },
},
},
})
})
test("maps both models.dev Bedrock packages to native providers", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -273,6 +358,35 @@ describe("AISDKNative", () => {
})
})
test("maps Vertex Gemini settings to the native Gemini route", () => {
expect(
map("@ai-sdk/google-vertex", {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
headers: { "x-test": "value" },
labels: { component: "opencode", environment: "test" },
location: "eu",
project: "vertex-project",
thinkingConfig: { thinkingLevel: "high" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
gemini: {
labels: { component: "opencode", environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
},
},
headers: { "x-test": "value" },
})
})
test("maps Vertex Anthropic settings to native Messages", () => {
expect(
map("@ai-sdk/google-vertex/anthropic", {
+37
View File
@@ -104,6 +104,43 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("lowers chronological system updates to wrapped user messages", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
system: "Initial instructions.",
messages: [
Message.user("Before."),
Message.system("Updated <rules> & constraints."),
Message.assistant("After."),
],
}),
)
expect(prepared.body.prompt).toEqual([
{ role: "system", content: "Initial instructions." },
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "user",
content: [
{
type: "text",
text: "<system-update>\nUpdated &lt;rules&gt; &amp; constraints.\n</system-update>",
},
],
},
{ role: "assistant", content: [{ type: "text", text: "After." }] },
])
}),
)
it.effect("leaves max output tokens unset when the request omits them", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+1 -3
View File
@@ -1,19 +1,17 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Command.node, [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
]),
)
@@ -0,0 +1,168 @@
import { describe, expect } from "bun:test"
import { LanguageModel, LLMClient, LLMEvent } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompactionPlugin } from "@opencode-ai/core/config/plugin/compaction"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Fiber, Layer, Option, Schema, Stream } from "effect"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const model = LanguageModel.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }),
})
const config = Config.testLayer()
const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[
SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
}),
],
[Config.node, config],
]),
),
)
describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: false, buffer: 20_000 }) }),
}),
new Document({
type: "document",
info: new Info({
compaction: new ConfigCompaction.Info({
buffer: 10_000,
keep: new ConfigCompaction.Keep({ tokens: 0 }),
}),
}),
}),
])
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
expect(compaction.required(nearInput)).toBe(false)
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
],
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
}),
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000 }) }),
}),
])
yield* bus.publish(Event.Updated, {})
yield* Effect.gen(function* () {
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(nearInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
})
expect(compaction.required(bufferedInput)).toBe(false)
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
}),
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(bufferedInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
}),
)
})
const session = Session.Info.make({
id: Session.ID.make("ses_compaction_config"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number) => ({
session,
model,
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const bufferedInput = input(85_000)
const nearInput = input(95_000)
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigShellPlugin } from "@opencode-ai/core/config/plugin/shell"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(ShellSelect.node)))
describe("ConfigShellPlugin.Plugin", () => {
it.live("applies the preferred shell and reloads changed config", () =>
Effect.gen(function* () {
const shell = yield* ShellSelect.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
expect(yield* shell.preferred()).toBe(configured)
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* shell.preferred()) !== configured) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
}).pipe(
Effect.provide(
Config.testLayer([new Document({ type: "document", info: new Info({ shell: process.execPath }) })]),
),
),
)
})
@@ -0,0 +1,66 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigSnapshotPlugin } from "@opencode-ai/core/config/plugin/snapshot"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
describe("ConfigSnapshotPlugin.Plugin", () => {
it.live("applies availability and reloads changed config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigSnapshotPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect(yield* snapshot.capture()).toBeUndefined()
yield* config.setEntries([new Document({ type: "document", info: new Info({ snapshots: true }) })])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* snapshot.capture()) !== undefined) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for snapshot config reload"))
}).pipe(
Effect.provide(
AppNodeBuilder.build(Snapshot.node, [
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
]),
),
)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.provide(PluginTestLayer),
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ snapshots: false }) })])),
),
)
})
@@ -0,0 +1,62 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
describe("ConfigToolOutputPlugin.Plugin", () => {
it.live("applies limits and reloads changed config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const output = yield* ToolOutput.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
yield* config.setEntries([
new Document({
type: "document",
info: new Info({
tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }),
}),
}),
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
const result = yield* output.truncate({ content: "one\ntwo" })
if (result.metadata?.truncated === false) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
}).pipe(
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.provide(PluginTestLayer),
Effect.provide(
Config.testLayer([
new Document({
type: "document",
info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }),
}),
]),
),
),
)
})
+52 -4
View File
@@ -457,8 +457,12 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
variants: [
{
id: VariantID.make("high"),
settings: { reasoningEffort: "high" },
id: VariantID.make("xhigh"),
settings: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
headers: { "x-variant": "high" },
body: {
store: false,
@@ -468,7 +472,7 @@ describe("ModelResolver", () => {
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("xhigh"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -478,7 +482,17 @@ describe("ModelResolver", () => {
temperature: 0.2,
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: { store: false, reasoningEffort: "high" },
openai: {
store: false,
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
})
}),
)
@@ -815,12 +829,46 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
[
"@ai-sdk/openai",
"@opencode-ai/ai/providers/openai",
{
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
{
openai: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
],
[
"@ai-sdk/anthropic",
"@opencode-ai/ai/providers/anthropic",
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
{ anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
],
[
"@ai-sdk/openai-compatible",
"@opencode-ai/ai/providers/openai-compatible",
{ reasoningEffort: "high" },
{ openai: { reasoningEffort: "high" } },
],
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@ai-sdk/google-vertex",
"@opencode-ai/ai/providers/google-vertex",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
+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")
})
})
+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),
}),
)
@@ -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),
})
}
@@ -137,7 +137,7 @@ describe("OpenAIPlugin", () => {
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
@@ -147,7 +147,7 @@ describe("OpenAIPlugin", () => {
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
@@ -194,7 +194,7 @@ describe("OpenAIPlugin", () => {
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(direct.headers).not.toHaveProperty("originator")
@@ -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),
})
+6 -22
View File
@@ -1,7 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
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"
@@ -9,6 +7,7 @@ import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -18,13 +17,7 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
@@ -207,26 +200,17 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[
Config.node,
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
configuredShell ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] : [],
),
}),
],
[Location.node, locationLayer],
]),
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
describe("pty create defaults", () => {
configuredTest("defaults command, login args, and cwd from config and location", () =>
configuredTest("defaults command, login args, and cwd from shell selection and location", () =>
Effect.gen(function* () {
if (!configuredShell) return
const pty = yield* Pty.Service
const shell = yield* ShellSelect.Service
yield* shell.transform((draft) => draft.configure(configuredShell))
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
pty.remove(created.id).pipe(Effect.ignore),
)
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
@@ -67,7 +66,6 @@ const client = Layer.mock(LLMClient.Service)({
},
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
@@ -83,7 +81,6 @@ const it = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[Config.node, config],
[SessionRunnerModel.node, models],
],
),
+33 -1
View File
@@ -28,6 +28,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = []
@@ -60,7 +61,7 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs image normalization and plugin readiness.
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
@@ -72,6 +73,12 @@ const locations = Layer.effect(
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.succeed(undefined) : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
@@ -1051,6 +1058,31 @@ describe("Session.prompt", () => {
)
})
describe("Session.revert", () => {
it.effect("waits for location plugins before staging", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* Session.Service
yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie)
yield* session.revert.stage({ sessionID, messageID })
}),
)
it.effect("waits for location plugins before clearing", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID, snapshot: Snapshot.ID.make("tree"), files: [] },
})
yield* session.revert.clear(sessionID)
}),
)
})
describe("Session.inbox", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
+25
View File
@@ -127,6 +127,31 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("applies availability transforms", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const registration = yield* snapshot.transform((draft) => draft.configure(false))
expect(yield* snapshot.capture()).toBeUndefined()
yield* registration.dispose
expect(yield* snapshot.capture()).toBeDefined()
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+9 -12
View File
@@ -1,9 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -15,18 +12,18 @@ import { it } from "./lib/effect"
const withStore = <A, E, R>(
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
info = new Info(),
limits?: { maxLines?: number; maxBytes?: number },
) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const config = Config.testLayer([new Document({ type: "document", info })])
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
[Config.node, config],
[Global.node, Global.layerWith({ data: tmp.path })],
])
return Effect.gen(function* () {
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
const output = yield* ToolOutput.Service
if (limits) yield* output.transform((draft) => draft.configure(limits))
return yield* body(output, yield* FSUtil.Service, tmp.path)
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -50,7 +47,7 @@ describe("ToolOutput", () => {
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -67,7 +64,7 @@ describe("ToolOutput", () => {
},
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
{ maxLines: 100, maxBytes: 5 },
),
)
@@ -86,7 +83,7 @@ describe("ToolOutput", () => {
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -119,7 +116,7 @@ describe("ToolOutput", () => {
metadata: { truncated: false },
})
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -133,7 +130,7 @@ describe("ToolOutput", () => {
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
{ maxLines: 2, maxBytes: 3 },
),
)
@@ -43,14 +43,16 @@ export async function startBackgroundCli(logger: Logger) {
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
...endpoint(service.url),
...endpoint(url.origin),
})
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
return {
url: service.url,
url: url.origin,
username: service.auth.username,
password: service.auth.password,
version: cli.version,
+3
View File
@@ -1,6 +1,7 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -39,6 +40,8 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
.handle(
"pty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
+3
View File
@@ -1,5 +1,6 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
@@ -19,6 +20,8 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
.handle(
"shell.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
+2 -6
View File
@@ -9,14 +9,12 @@ import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { Command } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
@@ -115,9 +113,7 @@ function makeRoutes<AuthError, AuthServices>(
}),
],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[Command.node, Command.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
+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,5 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
@@ -10,6 +10,7 @@ const money = new Intl.NumberFormat("en-US", {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const subagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
@@ -47,16 +48,34 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
<Match when={props.mode === "normal"}>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
<box flexDirection="row" flexShrink={1} minWidth={0}>
<Show when={live()}>
<box
flexShrink={0}
onMouseOver={() => setLiveHovered(true)}
onMouseOut={() => setLiveHovered(false)}
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
>
<text
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
wrapMode="none"
>
<Show when={shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
</text>
</box>
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
<Show when={status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
</text>
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
+75 -6
View File
@@ -1,3 +1,4 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
@@ -5,6 +6,7 @@ import {
createContext,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
onMount,
@@ -21,6 +23,8 @@ import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
@@ -28,7 +32,7 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
}
type State =
@@ -90,6 +94,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
const client = useClient()
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<
ReadonlyArray<
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
>
>([])
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -228,9 +239,16 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// Package resolution failures would otherwise retry a full npm install on
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
// A source that imports but fails setup must not tear down and restore its
// last-good generation again for every event in the same filesystem burst.
const setupFailures = new Map<string, { version: string; options: Desired["options"]; error: string }>()
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
const entries = [
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
]
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
@@ -238,7 +256,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
for (const plugin of builtins)
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const entry of entries) {
for (const source of entries) {
const entry = source.entry
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
@@ -259,11 +278,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
if (resolved.status === "unsupported") {
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
@@ -286,6 +306,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
})
continue
}
const setupFailure = setupFailures.get(target)
if (
previous &&
setupFailure?.version === resolved.version &&
sameOptions(setupFailure.options, options)
) {
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
desired.set(previous.plugin.id, {
plugin: previous.plugin,
source: previous.source,
target,
version: previous.version,
options: previous.options,
enabled: previous.active,
})
continue
}
setupFailures.delete(target)
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
@@ -360,6 +403,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const error = await activate(id).then(() => undefined, errorMessage)
if (!error) continue
errors.set(id, error)
if (item.target)
setupFailures.set(item.target, { version: item.version, options: item.options, error })
if (!fallback) continue
setStore("registrations", id, toRegistration(fallback))
if (!fallback.enabled) continue
@@ -439,7 +484,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
() => {
npmFailures.clear()
void enqueue(reconcile).then(
@@ -449,6 +494,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
},
),
)
const syncServerPlugins = () =>
client.api.plugin
.list({ location: data.location.default() })
.then((response) =>
setServerPlugins(
response.data.filter(
(
plugin,
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
readonly source: { readonly type: "package" }
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
),
),
)
.catch(() => undefined)
createEffect(
on(
() => JSON.stringify(data.location.default()),
() => void syncServerPlugins(),
),
)
onCleanup(client.event.on("plugin.updated", syncServerPlugins))
onCleanup(client.event.on("server.connected", syncServerPlugins))
onMount(() => {
let disposing: Promise<void> | undefined
const dispose = () => {
@@ -523,12 +591,13 @@ async function resolvePlugin(
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
install: boolean,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
@@ -1,18 +1,26 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { RGBA, TextRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { PromptFooter } from "../../src/feature-plugins/prompt/footer"
test("prompt footer separates simultaneous subagent, shell, and usage status", async () => {
const color = RGBA.fromInts(200, 200, 200)
const subdued = RGBA.fromInts(100, 100, 100)
const dispatched: string[] = []
const context = {
location: { directory: "/workspace" },
theme: { text: { default: color, subdued: color } },
theme: {
text: {
default: color,
subdued,
},
},
keymap: {
shortcuts: (id: string) =>
id === "session.child.first" ? ["ctrl+j"] : id === "command.palette.show" ? ["ctrl+p"] : [],
dispatch: (id: string) => dispatched.push(id),
},
data: {
session: {
@@ -39,6 +47,14 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
await app.renderOnce()
expect(app.captureCharFrame()).toContain("ctrl+j 1 subagent · 1 shell · $1.00")
expect(app.captureCharFrame()).toContain("ctrl+p commands")
await app.mockMouse.moveTo(2, 0)
const live = app.renderer.root.getChildren()[0]?.getChildren()[0]?.getChildren()[0]
expect(live).toBeInstanceOf(TextRenderable)
expect((live as TextRenderable).fg.toInts()).toEqual(color.toInts())
await app.mockMouse.click(2, 0)
expect(dispatched).toEqual(["session.child.first"])
} finally {
app.renderer.destroy()
}
+5
View File
@@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location")
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
if (url.pathname === "/api/plugin")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: [],
})
if (url.pathname === "/api/vcs")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
+63 -2
View File
@@ -4,6 +4,7 @@ import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
@@ -30,12 +31,26 @@ async function until(read: () => Promise<string>, expected: (value: string | und
return value
}
async function bootApp(directory: string) {
async function bootApp(
directory: string,
options?: {
plugins?: unknown[]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/plugin")
return json({
location: {
directory,
project: { id: "proj_test", directory, canonical: directory },
},
data: options?.plugins ?? [],
})
if (url.pathname !== "/api/fs/list") return
return json({
location: {
@@ -54,7 +69,7 @@ async function bootApp(directory: string) {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { resolve: options?.resolve ?? (async () => undefined) },
args: {},
log: () => {},
}).pipe(
@@ -73,6 +88,40 @@ async function bootApp(directory: string) {
}
}
test("loads an advertised package TUI entrypoint only from the local cache", async () => {
await using tmp = await tmpdir()
const marker = path.join(tmp.path, "marker.txt")
const entrypoint = path.join(tmp.path, "tui.ts")
await writeFile(entrypoint, lifecycleSource(marker, "test.package", "package"))
const resolutions: Array<{ spec: string; install?: boolean }> = []
await using app = await bootApp(tmp.path, {
plugins: [
{
id: "test.server",
source: { type: "package", package: "test-plugin@1.0.0" },
status: "active",
tui: true,
},
],
resolve: async (spec, install) => {
resolutions.push({ spec, install })
return pathToFileURL(entrypoint).href
},
})
expect(
await until(
() => readFile(marker, "utf8"),
(value) => value === "package:setup\n",
),
).toBe("package:setup\n")
expect(resolutions).toContainEqual({ spec: "test-plugin@1.0.0", install: false })
process.emit("SIGHUP")
await app.task
})
test("discovers an ancestor TUI plugin directory created after startup", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
@@ -222,12 +271,17 @@ test("a save whose setup throws restores the previous version", async () => {
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const source = path.join(directory, "a.ts")
const sourceB = path.join(directory, "b.ts")
await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
const readB = () => readFile(markerB, "utf8")
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
expect(await until(readB, (value) => value === "b1:setup\n")).toBe("b1:setup\n")
// The module imports fine but its setup throws — unlike an import failure,
// the swap has already torn down a1, so keep-last-good means restoring it.
@@ -246,6 +300,13 @@ export default {
"a1:setup\na1:cleanup\na1:setup\n",
)
// A later reconcile must not retry the same broken generation.
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe(
"b1:setup\nb1:cleanup\nb2:setup\n",
)
expect(await read()).toBe("a1:setup\na1:cleanup\na1:setup\n")
// Fixing the file swaps out the restored version normally.
await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
+33
View File
@@ -29,6 +29,7 @@ export interface Interface {
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -41,6 +42,16 @@ export function sanitize(pkg: string) {
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
export async function isRegistryPackage(pkg: string) {
const { default: npa } = await import("npm-package-arg")
try {
const result = npa(pkg)
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
} catch {
return false
}
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const entrypoint = subpaths
.map((subpath) => {
@@ -134,6 +145,23 @@ const layer = Layer.effect(
return resolveEntryPoint(first.name, first.path, options?.subpaths)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const name = (() => {
try {
return npa(pkg).name ?? pkg
} catch {
return pkg
}
})()
const dir = path.join(directory(pkg), "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
@@ -187,6 +215,7 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
which,
})
}),
@@ -204,6 +233,10 @@ export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args))
}
export async function resolve(...args: Parameters<Interface["resolve"]>) {
return runPromise((svc) => svc.resolve(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
+35 -1
View File
@@ -99,6 +99,32 @@ an isolated cache. Package installation does not run lifecycle scripts.
Published packages should expose their plugin entrypoint and include every
runtime import in `dependencies`.
Install a package plugin globally with the CLI:
```sh
opencode2 plugin add opencode-acme-plugin@1.2.0
```
This installs and inspects the package before changing configuration. Packages
with a server entrypoint are added to global `opencode.json(c)`. Packages that
only expose `./tui` are added to global `cli.json` instead.
The command accepts npm registry package names with an optional version,
dist-tag, or semver range. Configure local paths directly instead; Git, tarball,
and npm alias targets are not accepted by `plugin add`.
List configured and active plugins, or remove a package from both global server
and TUI configuration:
```sh
opencode2 plugin list
opencode2 plugin list --builtin
opencode2 plugin remove opencode-acme-plugin@1.2.0
```
Built-in server plugins are hidden from the default list. Removing a plugin
keeps its package cache available for later reuse.
Local files and local package directories are imported directly. OpenCode does
**not** install their dependencies. Install dependencies in a `package.json`
visible from the plugin file, for example:
@@ -397,13 +423,21 @@ manifest is:
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode-ai/plugin": "beta"
}
}
```
Packages with a TUI entrypoint should set `tui: true` on their server plugin
definition. A locally connected TUI loads the package's `./tui` export from the
existing OpenCode package cache. A TUI connected to a remote server skips it
when that package is not installed locally.
Use versions compatible with the OpenCode release you target and test the
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts