Compare commits

..

1 Commits

Author SHA1 Message Date
Victor Navarro e4e1de007f fix(ui): hide menus after close 2026-08-06 10:22:01 +00:00
111 changed files with 1192 additions and 7190 deletions
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
+1 -4
View File
@@ -68,10 +68,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "Show resolved configuration" }),
],
commands: [Spec.make("agents", { description: "List all agents" })],
}),
Spec.make("console", {
description: "Manage OpenCode Console access",
@@ -1,19 +0,0 @@
import { EOL } from "os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.debug.commands.config,
Effect.fn("cli.debug.config")(function* () {
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 entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
}),
)
@@ -22,7 +22,6 @@ export default Runtime.handler(Commands, (input) =>
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
mismatch: "replace",
onStart: (reason, previousVersion) => {
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write(
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
case "needs_auth":
return "⚠"
case "failed":
case "needs_client_registration":
return "✗"
default:
return "○"
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
switch (status.status) {
case "needs_auth":
return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed":
return `failed: ${status.error}`
default:
+1 -5
View File
@@ -10,11 +10,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({
server: serverURL,
standalone: input.standalone,
mismatch: "replace",
})
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
const config = yield* Config.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
-1
View File
@@ -22,7 +22,6 @@ const Handlers = Runtime.handlers(Commands, {
},
debug: {
agents: () => import("./commands/handlers/debug/agents"),
config: () => import("./commands/handlers/debug/config"),
},
console: {
login: () => import("./commands/handlers/console/login"),
@@ -42,10 +42,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
const mismatch = args.mismatch ?? "ignore"
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
const options = yield* ServiceConfig.options()
return {
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
service: managedService(options),
} satisfies Resolved
})
+2 -2
View File
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
}
})
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
export const options = Effect.fnUntraced(function* () {
const { file, legacyRegistrationFiles } = yield* paths
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
version: OPENCODE_VERSION,
command: [...selfCommand(), "serve", "--service"],
}
})
-83
View File
@@ -1,83 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
describe("debug config command", () => {
test("is included in troubleshooting help", async () => {
const [debug, config] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "config", "--help"])])
expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("config")
expect(debug.stdout).toContain("Show resolved configuration")
expect(config.exitCode).toBe(0)
expect(config.stdout).toContain("opencode debug config [flags]")
})
test("prints config entries from the invoking directory without reordering permissions", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-config-"))
const project = path.join(import.meta.dir, "..")
const registration = path.join(root, "state", "opencode", "service-local.json")
const entries = [
{
type: "document",
path: path.join(project, "opencode.json"),
info: {
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
},
},
{ type: "file", path: path.join(project, "opencode.json") },
]
let requested: URL | undefined
const authorization: Array<string | null> = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
requested = url
authorization.push(request.headers.get("authorization"))
return Response.json(entries)
},
})
try {
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
registration,
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, password: "secret" }),
)
const result = await cli(["debug", "config"], project, { XDG_STATE_HOME: path.join(root, "state") })
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(JSON.parse(result.stdout)).toEqual(entries)
expect(requested?.pathname).toBe("/api/config")
expect(requested?.searchParams.get("location[directory]")).toBe(project)
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
} finally {
server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
})
async function cli(args: string[], cwd = path.join(import.meta.dir, ".."), env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
cwd,
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
@@ -55,17 +55,3 @@ test("resolution groups Effect-native lifecycle operations only for the managed
await fs.rm(root, { recursive: true, force: true })
}
})
test("service options only require a matching version when requested", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-options-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
try {
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
-2
View File
@@ -8,7 +8,6 @@ import {
} from "@opencode-ai/protocol/client"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Config } from "@opencode-ai/schema/config"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { EventLog } from "@opencode-ai/schema/event-log"
@@ -49,7 +48,6 @@ const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmi
const effectTypeReferences = [
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
...namespaceTypes("Config", "@opencode-ai/schema/config", Config),
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
-12
View File
@@ -38,7 +38,6 @@ import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Config } from "@opencode-ai/schema/config"
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
@@ -1596,16 +1595,6 @@ export interface WebsearchApi<E = never> {
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly server: ServerApi<E>
@@ -1636,5 +1625,4 @@ export interface AppApi<E = never> {
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
readonly websearch: WebsearchApi<E>
readonly config: ConfigApi<E>
}
@@ -220,8 +220,6 @@ import type {
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js"
import { ClientError } from "./client-error"
@@ -1243,13 +1241,6 @@ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
query: Endpoint28_1(raw),
})
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint29_0Output>()(
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
server: adaptGroup1(raw["server.server"]),
@@ -1280,7 +1271,6 @@ const adaptClient = (raw: RawClient) => ({
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
websearch: adaptGroup28(raw["server.websearch"]),
config: adaptGroup29(raw["server.config"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
-2
View File
@@ -8,7 +8,6 @@ export type {
AppApi,
CatalogApi,
CommandApi,
ConfigApi,
EventApi,
IntegrationApi,
ModelApi,
@@ -21,7 +20,6 @@ export type {
} from "./api.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { EventLog } from "@opencode-ai/schema/event-log"
+18 -62
View File
@@ -53,7 +53,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const contenders = new Set<Contender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
@@ -83,18 +82,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const registration = yield* registered(options.file, true)
const info = registration.info
const service = registration.service
if (registration.timedOut && info !== undefined) {
timeouts = {
info,
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -195,10 +182,6 @@ type LocalService = {
}
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
return (yield* probeResult(info, allowLegacy)).service
})
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
const endpoint = {
url: info.url,
auth:
@@ -206,53 +189,39 @@ const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(2_000)
const result = yield* Effect.promise(() =>
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
signal,
})
.then(async (response) => ({ response, body: (await response.json()) as unknown }))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
const body = result.value.body
signal: AbortSignal.timeout(2_000),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined) return undefined
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const health = decodeHealth(body)
if (Option.isSome(health)) {
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && health.value.version !== info.version)
return { service: undefined, timedOut: false }
if (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined
return {
service: {
info,
endpoint,
version: health.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService,
timedOut: false,
}
info,
endpoint,
version: health.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService
}
if (
!allowLegacy ||
Option.isNone(decodeLegacyHealth(body)) ||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
)
return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
}
return undefined
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
})
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
const info = yield* read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(yield* probeResult(info, allowLegacy)) }
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: yield* probe(info, allowLegacy) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
@@ -280,19 +249,6 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
if (Option.isSome(done)) return
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll))
})
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
const requested = yield* requestStop(service)
if (requested === "rejected") return
-1
View File
@@ -2,7 +2,6 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type IntegrationApi = Client["integration"]
export type ModelApi = Client["model"]
@@ -216,8 +216,6 @@ import type {
WebsearchProvidersOutput,
WebsearchQueryInput,
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -1813,20 +1811,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
config: {
get: (input?: ConfigGetInput, requestOptions?: RequestOptions) =>
request<ConfigGetOutput>(
{
method: "GET",
path: `/api/config`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
}
}
+9 -183
View File
@@ -259,6 +259,8 @@ export type McpStatusFailed = { status: "failed"; error: string }
export type McpStatusNeedsAuth = { status: "needs_auth" }
export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string }
export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string }
export type McpResourceTemplate = {
@@ -1259,7 +1261,13 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type McpServer = {
name: string
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
status:
| McpStatusConnected
| McpStatusPending
| McpStatusDisabled
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
integrationID?: string
}
@@ -1690,180 +1698,6 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type ConfigEntry =
| {
type: "document"
path?: string | null
info: {
$schema?: string | null
shell?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
default_agent?: string | null
autoupdate?: boolean | "notify" | null
share?: "manual" | "auto" | "disabled" | null
enterprise?: { url?: string | null } | null
username?: string | null
permissions?: PermissionRuleset | null
agents?: {
[x: string]: {
model?: string | { providerID: string; model: string; variant?: string | null } | null
request?: { headers?: { [x: string]: string } | null; body?: { [x: string]: JsonValue } | null } | null
system?: string | null
description?: string | null
mode?: "subagent" | "primary" | "all" | null
hidden?: boolean | null
color?: string | null
steps?: number | null
disabled?: boolean | null
permissions?: PermissionRuleset | null
}
} | null
snapshots?: boolean | null
watcher?: { ignore?: Array<string> | null } | null
formatter?:
| boolean
| {
[x: string]: {
disabled?: boolean | null
command?: Array<string> | null
environment?: { [x: string]: string } | null
extensions?: Array<string> | null
}
}
| null
lsp?:
| boolean
| {
[x: string]:
| { disabled: true }
| {
command: Array<string>
extensions?: Array<string> | null
disabled?: boolean | null
env?: { [x: string]: string } | null
initialization?: { [x: string]: JsonValue } | null
}
}
| null
media?: {
image?: {
auto_resize?: boolean | null
max_width?: number | null
max_height?: number | null
max_base64_bytes?: number | null
} | null
} | null
tool_output?: { max_lines?: number | null; max_bytes?: number | null } | null
mcp?: {
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
servers?: {
[x: string]:
| {
type: "local"
command: Array<string>
cwd?: string | null
environment?: { [x: string]: string } | null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
}
| {
type: "remote"
url: string
headers?: { [x: string]: string } | null
oauth?:
| {
client_id?: string | null
client_secret?: string | null
scope?: string | null
callback_port?: number | null
redirect_uri?: string | null
}
| false
| null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
}
} | null
} | null
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
skills?: Array<string> | null
commands?: {
[x: string]: {
template: string
description?: string | null
agent?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
subtask?: boolean | null
}
} | null
instructions?: Array<string> | null
references?: {
[x: string]:
| string
| { repository: string; branch?: string | null; description?: string | null; hidden?: boolean | null }
| { path: string; description?: string | null; hidden?: boolean | null }
} | null
websearch?: { provider: string } | null
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } | null }> | null
warming?: boolean | { prompt?: string | null; interval?: string | null; duration?: string | null } | null
providers?: {
[x: string]: {
name?: string | null
env?: Array<string> | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
models?: {
[x: string]: {
modelID?: string | null
family?: string | null
name?: string | null
compatibility?: ModelCompatibility | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
capabilities?: ModelCapabilities | null
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
}> | null
cost?:
| {
tier?: { type: "context"; size: number } | null
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
}
| Array<{
tier?: { type: "context"; size: number } | null
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
}>
| null
disabled?: boolean | null
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
}
} | null
}
} | null
experimental?: {
subagent_depth?: number | null
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
} | null
}
}
| { type: "directory"; path: string }
| { type: "file"; path: string }
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionPendingUser = {
@@ -4759,11 +4593,3 @@ export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
export type ConfigGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ConfigGetOutput = Array<ConfigEntry>
-1
View File
@@ -3,7 +3,6 @@ export type {
AgentApi,
CatalogApi,
CommandApi,
ConfigApi,
EventApi,
IntegrationApi,
ModelApi,
+15 -63
View File
@@ -34,7 +34,6 @@ async function discoverLocal(options: DiscoverOptions) {
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const deadline = Date.now() + 120_000
const contenders = new Set<Contender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
@@ -63,19 +62,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
info: registration.info,
count:
timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (registration.service !== undefined) {
spawnDelay = 5_000
@@ -159,10 +145,6 @@ type LocalService = {
}
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
return (await probeResult(info, allowLegacy)).service
}
async function probeResult(info: Info, allowLegacy = false) {
const endpoint = {
url: info.url,
auth:
@@ -170,48 +152,30 @@ async function probeResult(info: Info, allowLegacy = false) {
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(2_000)
const result = await fetch(new URL("/api/health", info.url), {
const response = await fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
signal,
})
.then(async (response) => ({
response,
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
}))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
const body = result.value.body
signal: AbortSignal.timeout(2_000),
}).catch(() => undefined)
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
if (body !== undefined && "version" in body && "pid" in body) {
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && body.version !== info.version)
return { service: undefined, timedOut: false }
if (body.pid !== info.pid) return undefined
if (info.version !== undefined && body.version !== info.version) return undefined
return {
service: {
info,
endpoint,
version: body.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService,
timedOut: false,
info,
endpoint,
version: body.version,
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
legacy: false,
}
}
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
}
if (!allowLegacy || body?.healthy !== true) return undefined
return { info, endpoint, state: "ready", legacy: true }
}
async function registered(file?: string, allowLegacy = false) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(await probeResult(info, allowLegacy)) }
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: await probe(info, allowLegacy) }
}
async function find(options: { readonly file?: string }) {
@@ -245,18 +209,6 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function evict(info: Info, options: { readonly file?: string }) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid)) return
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }) {
const requested = await requestStop(service)
if (requested === "rejected") return
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Model } from "@opencode-ai/schema/model"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
@@ -11,7 +10,6 @@ const Client = await import("../src/effect")
test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Agent).toBe(Agent)
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Session).toBe(Session)
})
-4
View File
@@ -47,10 +47,6 @@ const server = Bun.serve({
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "hanging") {
await appendFile(registration + ".requests", process.pid + "\n")
return new Promise<Response>(() => {})
}
if (mode === "modern" && requests === 1) {
await writeFile(registration + ".first-request", "")
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
@@ -70,32 +70,6 @@ test("reports a failed registered service", async () => {
)
})
test("evicts an unresponsive registered service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
stdout: "ignore",
stderr: "inherit",
})
processes.push(existing)
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
})
const replacement = await Bun.file(registration).json()
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
}, 20_000)
test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
-29
View File
@@ -33,7 +33,6 @@ test("exposes every standard HTTP API group", () => {
"vcs",
"debug",
"websearch",
"config",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
@@ -51,34 +50,6 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("config.get returns ordered config entries for a location", async () => {
let request: Request | undefined
const entries = [
{
type: "document" as const,
path: "/tmp/project/opencode.json",
info: {
permissions: [
{ action: "shell", resource: "*", effect: "ask" as const },
{ action: "shell", resource: "git status", effect: "allow" as const },
],
},
},
{ type: "file" as const, path: "/tmp/project/opencode.json" },
]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json(entries)
},
})
expect(await client.config.get({ location: { directory: "/tmp/project" } })).toEqual(entries)
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
-24
View File
@@ -70,30 +70,6 @@ test("reports a failed registered service without spawning", async () => {
expect(process.exitCode).toBe(null)
})
test("evicts an unresponsive registered service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
}),
)
const replacement = await Bun.file(registration).json()
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
process.kill(replacement.pid, "SIGTERM")
}, 20_000)
test("requests graceful stop of the exact service instance", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+148 -22
View File
@@ -5,16 +5,8 @@ import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
Directory,
Document,
File,
Info,
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { Bus } from "./bus"
@@ -23,11 +15,141 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigMedia } from "./config/media"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp"
import { ConfigModel } from "./config/model"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigWebSearch } from "./config/websearch"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
import { ConfigWarming } from "./config/warming"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
import { WellKnown } from "./wellknown"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(Schema.optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(Schema.optional),
})
.pipe(Schema.optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
export class Directory extends Schema.Class<Directory>("Config.Directory")({
type: Schema.Literal("directory"),
path: AbsolutePath,
}) {}
export class File extends Schema.Class<File>("Config.File")({
type: Schema.Literal("file"),
path: AbsolutePath,
}) {}
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
type: Schema.Literal("agents"),
path: AbsolutePath,
}) {}
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
type: Schema.Literal("claude"),
path: AbsolutePath,
}) {}
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
.filter((entry): entry is Document => entry.type === "document")
@@ -174,17 +296,21 @@ export const layer = (options?: Options) => Layer.effect(
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude"),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
...((yield* fs.isDir(globalClaudeDirectory))
? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })]
: []),
...discovered
.filter((item) => path.basename(item) === ".claude")
.map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })),
]
const agents = [
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents"),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
...((yield* fs.isDir(globalAgentsDirectory))
? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })]
: []),
...discovered
.filter((item) => path.basename(item) === ".agents")
.map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })),
]
const directories = [
globalDirectory,
@@ -233,13 +359,13 @@ export const layer = (options?: Options) => Layer.effect(
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...(supplementary[0] ?? []),
...explicit,
...direct,
...supplementary.slice(1).flat(),
...(yield* loadWellknown().pipe(Effect.orDie)),
...content,
]
})
@@ -282,7 +408,7 @@ export const layer = (options?: Options) => Layer.effect(
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* bus.publish(Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
}),
),
)
@@ -1,10 +1,10 @@
export * as ConfigAgent from "./agent.js"
export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigProvider } from "./provider"
import { ConfigModel } from "./model"
import { PositiveInt } from "../schema"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
@@ -1,7 +1,7 @@
export * as ConfigCommand from "./command.js"
export * as ConfigCommand from "./command"
import { Schema } from "effect"
import { ConfigModel } from "./model.js"
import { ConfigModel } from "./model"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
@@ -1,7 +1,7 @@
export * as ConfigCompaction from "./compaction.js"
export * as ConfigCompaction from "./compaction"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema.js"
import { NonNegativeInt } from "../schema"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(Schema.optional),
@@ -1,8 +1,8 @@
export * as ConfigExperimental from "./experimental.js"
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
import { NonNegativeInt } from "../schema"
import { ConfigPolicy } from "./policy"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
@@ -1,4 +1,4 @@
export * as ConfigFormatter from "./formatter.js"
export * as ConfigFormatter from "./formatter"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigLSP from "./lsp.js"
export * as ConfigLSP from "./lsp"
import { Schema } from "effect"
@@ -1,8 +1,10 @@
export * as ConfigMCP from "./mcp.js"
export * as ConfigMCP from "./mcp"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { Mcp } from "@opencode-ai/schema/mcp"
// The MCP server config is a public wire contract (used by the mcp.add route), so it lives in
// @opencode-ai/schema and is re-exported here.
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
export const Local = Mcp.LocalConfig
@@ -1,7 +1,7 @@
export * as ConfigMedia from "./media.js"
export * as ConfigMedia from "./media"
import { Schema } from "effect"
import { PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
@@ -1,8 +1,8 @@
export * as ConfigModel from "./model.js"
export * as ConfigModel from "./model"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -1,4 +1,4 @@
export * as ConfigPlugin from "./plugin.js"
export * as ConfigPlugin from "./plugin"
import { Schema } from "effect"
+6 -7
View File
@@ -1,12 +1,11 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Agent } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigAgentV1 } from "../../v1/config/agent"
@@ -25,7 +24,7 @@ const legacySources = [
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
@@ -64,13 +63,13 @@ export const Plugin = define({
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Document => document !== undefined),
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Document[] }
const loaded = { documents: [] as Config.Document[] }
const reload = load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
@@ -140,7 +139,7 @@ export const Plugin = define({
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
// check: directory-level events such as renames carry no per-file paths.
function isAgentSource(entries: Entry[], file: string) {
function isAgentSource(entries: Config.Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
@@ -209,5 +208,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
}),
)
if (!info) return
return new Document({ type: "document", path: file.filepath, info })
return new Config.Document({ type: "document", path: file.filepath, info })
}
+3 -4
View File
@@ -1,13 +1,12 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Command } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
@@ -28,7 +27,7 @@ export const Plugin = define({
)
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Info["commands"] }[] }
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
const reload = load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
@@ -76,7 +75,7 @@ const sourceDirectories = ["command", "commands"] as const
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
// directory-level events such as renames carry no per-file paths.
function isCommandSource(entries: Entry[], file: string) {
function isCommandSource(entries: Config.Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
+1 -2
View File
@@ -1,7 +1,6 @@
export * as ConfigPolicyPlugin from "./policy"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Wildcard } from "../../util/wildcard"
@@ -14,7 +13,7 @@ export const Plugin = define({
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.filter((entry): entry is Config.Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
+2 -3
View File
@@ -1,7 +1,6 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
@@ -108,8 +107,8 @@ export const Plugin = define({
}),
})
function configuredProviders(entries: readonly Entry[]) {
function configuredProviders(entries: readonly Config.Entry[]) {
return entries
.filter((entry): entry is Document => entry.type === "document")
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((file) => Object.entries(file.info.providers ?? {}))
}
+2 -3
View File
@@ -1,11 +1,10 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "@opencode-ai/util/global"
@@ -20,7 +19,7 @@ export const Plugin = define({
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
@@ -1,4 +1,4 @@
export * as ConfigPolicy from "./policy.js"
export * as ConfigPolicy from "./policy"
import { Schema } from "effect"
@@ -1,8 +1,8 @@
export * as ConfigProvider from "./provider.js"
export * as ConfigProvider from "./provider"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Money } from "@opencode-ai/schema/money"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
@@ -1,4 +1,4 @@
export * as ConfigReference from "./reference.js"
export * as ConfigReference from "./reference"
import { Schema } from "effect"
@@ -1,7 +1,7 @@
export * as ConfigToolOutput from "./tool-output.js"
export * as ConfigToolOutput from "./tool-output"
import { Schema } from "effect"
import { PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(Schema.optional),
@@ -1,4 +1,4 @@
export * as ConfigWarming from "./warming.js"
export * as ConfigWarming from "./warming"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigWatcher from "./watcher.js"
export * as ConfigWatcher from "./watcher"
import { Schema } from "effect"
@@ -1,7 +1,7 @@
export * as ConfigWebSearch from "./websearch.js"
export * as ConfigWebSearch from "./websearch"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Schema } from "effect"
import { WebSearch } from "../websearch.js"
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
provider: WebSearch.ID,
@@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
@@ -42,7 +41,7 @@ const layer = Layer.effect(
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = Protected.isHome(location.directory)
+1 -1
View File
@@ -29,7 +29,7 @@ import {
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigMCP } from "../config/mcp"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
+2 -3
View File
@@ -3,12 +3,11 @@ export * as MCP from "./index"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { Document } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { createHash } from "node:crypto"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "../config"
import { ConfigMCP } from "../config/mcp"
import { Credential } from "../credential"
import { Bus } from "../bus"
import { Form } from "../form"
@@ -179,7 +178,7 @@ export const layer = (options?: Options) => Layer.effect(
const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
// Global MCP timeout defaults, later config files overriding earlier ones.
const timeout = Object.assign(
{},
+1 -1
View File
@@ -5,7 +5,7 @@ import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprot
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigMCP } from "../config/mcp"
import { OauthCallbackPage } from "../oauth/page"
import type { Integration } from "../integration"
+58 -2
View File
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
}),
)
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = {
app: host.app,
options: host.options,
@@ -265,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: sessionHook,
create: (input) =>
run(
host.session.create(
@@ -1,5 +1,4 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
@@ -141,6 +140,14 @@ const oauth = (app: App.Info) => ({
}),
}) satisfies IntegrationOAuthMethodRegistration
function shouldUseResponses(modelID: string) {
// Copilot supports Responses for GPT-5 class models, except mini variants
// which still need the chat-completions endpoint.
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) return false
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) {
@@ -262,7 +269,7 @@ export const GithubCopilotPlugin = define({
return
}
const id = evt.model.modelID ?? evt.model.id
evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
}),
)
}),
+9 -9
View File
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
})
}
})
yield* ctx.session.hook("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
yield* ctx.session.hook("http", (evt) =>
evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(request.url)
request.headers.set("originator", "opencode")
request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return next(request)
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
}),
)
+7 -7
View File
@@ -1,8 +1,7 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
@@ -10,6 +9,7 @@ import { Agent } from "../agent"
import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { ConfigPlugin } from "../config/plugin"
import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
@@ -83,15 +83,15 @@ function parse(input: ConfigPlugin.Plugin): Operation {
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
@@ -208,7 +208,7 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
function isPluginSource(entries: readonly Config.Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
@@ -243,7 +243,7 @@ const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
entries: readonly Entry[],
entries: readonly Config.Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
+3 -4
View File
@@ -2,7 +2,6 @@ export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
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"
import { Bus } from "../bus"
@@ -20,7 +19,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
@@ -149,9 +148,9 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const settings = (documents: readonly Entry[]) => {
const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Document => entry.type === "document")
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
+35 -21
View File
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
const options: StreamOptions = {
http: (request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: yield* HttpClientRequest.toWeb(request),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
const send = (input: Request) =>
Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
latest = sent
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
+1 -1
View File
@@ -1,8 +1,8 @@
export * as ConfigV1 from "./config"
import { Schema } from "effect"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
import { ConfigReference } from "../../config/reference"
import { ConfigAgentV1 } from "./agent"
import { ConfigAttachmentV1 } from "./attachment"
import { ConfigCommandV1 } from "./command"
+9 -10
View File
@@ -5,7 +5,6 @@ import { Effect, Fiber, Schema, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Directory, Document, Info } from "@opencode-ai/schema/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -20,7 +19,7 @@ import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
...Agent.Info.default(Agent.ID.make("test")).permissions,
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
@@ -72,7 +71,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
@@ -93,7 +92,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
},
}),
}),
new Document({
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "read", resource: "*", effect: "allow" }],
@@ -154,7 +153,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.gen(function* () {
const agents = yield* Agent.Service
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
agents: {
@@ -174,7 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
},
}),
}),
new Document({
new Config.Document({
type: "document",
info: decode({
agents: {
@@ -219,7 +218,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
yield* agents.transform((editor) => editor.update(build, () => {}))
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({ agents: { build: { disabled: true } } }),
}),
@@ -277,7 +276,7 @@ Use native v2 fields.`,
const agents = yield* Agent.Service
const global = yield* Global.Service
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
}),
@@ -426,7 +425,7 @@ Use native v2 fields.`,
})
function directoryEntry(directory: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
function sourceCases() {
@@ -523,7 +522,7 @@ function loadHomePermissions(home: string) {
const build = Agent.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
+6 -6
View File
@@ -3,7 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { advance, drain } from "../lib/clock"
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Command } from "@opencode-ai/core/command"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
@@ -35,7 +35,7 @@ const it = testEffect(
[Location.node, testLocationLayer],
]),
)
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigCommandPlugin.Plugin", () => {
it.live("loads inline and file-based commands in config order", () =>
@@ -63,7 +63,7 @@ Review files`,
const command = yield* Command.Service
const bus = yield* Bus.Service
const update = yield* bus.publish(Event.Updated, {})
const update = yield* bus.publish(ConfigSchema.Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
yield* ConfigCommandPlugin.Plugin.effect(
host({
@@ -77,11 +77,11 @@ Review files`,
).pipe(
Effect.provide(
Config.testLayer([
new Document({
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Inline review" } } }),
}),
new Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]),
),
)
@@ -333,7 +333,7 @@ function watchReady(config: Config.Interface, directory: string) {
}
function directoryEntry(directory: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
function sourceCases() {
+19 -51
View File
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigModel } from "@opencode-ai/schema/config/model"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigModel } from "@opencode-ai/core/config/model"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -161,7 +161,7 @@ describe("Config", () => {
const bus = yield* Bus.Service
const watcher = yield* Watcher.Test
const changed = yield* bus
.subscribe(Event.Updated)
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
@@ -234,14 +234,14 @@ describe("Config", () => {
yield* Effect.sleep("10 millis")
const removed = yield* bus
.subscribe(Event.Updated)
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => fs.rm(file))
yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
const recreated = yield* bus
.subscribe(Event.Updated)
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
@@ -273,7 +273,7 @@ describe("Config", () => {
const test = yield* Config.Test
expect(yield* config.entries()).toEqual([])
const entry = new Document({ type: "document", info: new Info({}) })
const entry = new Config.Document({ type: "document", info: new Config.Info({}) })
yield* test.setEntries([entry])
expect(yield* config.entries()).toEqual([entry])
@@ -289,16 +289,16 @@ describe("Config", () => {
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
new Document({
new Config.Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Config.Document({ type: "document", info: new Config.Info({}) }),
new Config.Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
@@ -372,7 +372,7 @@ describe("Config", () => {
const bus = yield* Bus.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
const updated = yield* bus
.subscribe(Event.Updated)
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
key = "next"
@@ -418,7 +418,7 @@ describe("Config", () => {
Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
@@ -661,45 +661,13 @@ describe("Config", () => {
const entries = yield* config.entries()
expect(entries).toEqual([
new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
const project = path.join(home, "project")
yield* Effect.promise(() =>
Promise.all([
fs.mkdir(path.join(home, ".claude"), { recursive: true }),
fs.mkdir(path.join(home, ".agents"), { recursive: true }),
fs.mkdir(project, { recursive: true }),
]),
)
const entries = yield* Config.Service.use((config) => config.entries()).pipe(
Effect.provide(testLayer(project, global)),
)
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
AbsolutePath.make(path.join(home, ".claude")),
])
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
AbsolutePath.make(path.join(home, ".agents")),
])
}),
),
),
)
it.live("does not watch ecosystem config roots", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -761,7 +729,7 @@ describe("Config", () => {
expect(documents).toHaveLength(2)
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
expect(documents[0]).toBeInstanceOf(Document)
expect(documents[0]).toBeInstanceOf(Config.Document)
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
@@ -1209,7 +1177,7 @@ describe("Config", () => {
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents).toHaveLength(1)
expect(documents[0]?.info).toBeInstanceOf(Info)
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.default_agent).toBe("reviewer")
expect(documents[0]?.info.snapshots).toBe(false)
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ConfigModel } from "@opencode-ai/schema/config/model"
import { ConfigModel } from "@opencode-ai/core/config/model"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Schema } from "effect"
+5 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
@@ -12,10 +12,10 @@ import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
new Document({
new Config.Document({
type: "document",
info: decode({
experimental: {
@@ -24,7 +24,7 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
}),
})
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
@@ -78,7 +78,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
yield* bus.publish(Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
)
+10 -11
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { Effect, Schema, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
@@ -15,7 +14,7 @@ import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
@@ -47,7 +46,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
)
}
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("defaults custom models to agent capabilities", () =>
@@ -56,7 +55,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
providers: {
@@ -91,7 +90,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
})
})
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
providers: {
@@ -130,7 +129,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
providers: {
@@ -179,7 +178,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
providers: {
@@ -190,7 +189,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Document({
new Config.Document({
type: "document",
info: decode({
providers: {
@@ -224,7 +223,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const entries = [
new Document({
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
@@ -256,7 +255,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Document({
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
@@ -290,7 +289,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Document({
new Config.Document({
type: "document",
info: decode({
providers: {
+4 -4
View File
@@ -1,6 +1,6 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Command } from "@opencode-ai/core/command"
@@ -22,7 +22,7 @@ import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
@@ -54,7 +54,7 @@ describe("config plugin reloads", () => {
yield* test.setEntries([config("second")])
yield* Effect.yieldNow
yield* bus.publish(Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
return (
@@ -83,7 +83,7 @@ describe("config plugin reloads", () => {
})
function config(name: string) {
return new Document({
return new Config.Document({
type: "document",
path: document,
info: decode({
+5 -6
View File
@@ -2,7 +2,6 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
@@ -13,7 +12,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(Layer.empty)
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigSkillPlugin.Plugin", () => {
it.effect("registers configured skill directories and URLs", () =>
@@ -44,10 +43,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
Effect.provide(
Config.testLayer([
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Document({
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({
type: "document",
info: decode({
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Duration, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { Config } from "../../src/config"
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("config warming", () => {
test("accepts boolean enablement", () => {
+3 -4
View File
@@ -5,7 +5,6 @@ import { Effect, Layer, Schema, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Npm } from "@opencode-ai/util/npm"
import { Document, Info } from "@opencode-ai/schema/config"
import { Config } from "../src/config"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
@@ -14,16 +13,16 @@ import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
type ConfigInput = typeof Info.Encoded
type ConfigInput = typeof Config.Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Document({
new Config.Document({
type: "document",
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
+3 -4
View File
@@ -12,8 +12,7 @@ import {
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -163,9 +162,9 @@ function resourceMcpLayer(
Layer.provide(
Layer.mergeAll(
Config.testLayer([
new Document({
new Config.Document({
type: "document",
info: new Info({
info: new Config.Info({
mcp: new ConfigMCP.Info({
servers: {
resources:
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
http: { body: { custom_extension: { enabled: true } } },
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.max_output_tokens).toBeUndefined()
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
}),
)
+82 -25
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP request and response hooks", () =>
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http.request", (event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
})
await ctx.session.hook("http", (event) => {
event.use((request, next) => next(request))
})
},
}),
).effect(host)
const context = {
sessionID: Session.ID.make("ses_promise_session_http"),
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
const request = yield* hooks.trigger("session", "http.request", {
...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
})
const response = yield* hooks.trigger("session", "http.response", {
...context,
request: request.request,
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
})
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
)
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(request.request.url).toBe("https://provider.test/changed")
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: new Request(url, { method: "POST", body: "{}" }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
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.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 400_000,
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
+1 -2
View File
@@ -1,7 +1,6 @@
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"
@@ -214,7 +213,7 @@ const configuredIt = testEffect(
entries: () =>
Effect.succeed(
configuredShell
? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })]
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
: [],
),
}),
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const headers: Array<string | undefined> = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
headers.push(request.headers["x-hook"])
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
}),
),
)
const httpIt = testEffect(
const retryIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
headers.length = 0
const seen: string[] = []
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http.request", (event) =>
Effect.sync(() => {
seen.push("request")
event.request.headers.set("x-hook", "effect")
}),
)
yield* pluginHost.session.hook("http.response", (event) =>
Effect.gen(function* () {
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
event.response = new Response(
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
event.response,
)
}),
yield* pluginHost.session.hook("http", (event) =>
event.use((request, next) =>
Effect.gen(function* () {
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
return yield* next(request)
}),
),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST"])
expect(headers).toEqual(["effect"])
expect(seen).toEqual(["request", "response:200:effect"])
expect(bodies).toHaveLength(1)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
type: "assistant",
content: [{ type: "text", text: "Hooked!" }],
})
expect(bodies[1]).toEqual(bodies[0])
}),
)
})
+5 -6
View File
@@ -49,10 +49,9 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool"
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
import type { Info } from "@opencode-ai/schema/tool"
import {
InstructionStateTable,
SessionPendingTable,
@@ -229,7 +228,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
)
@@ -335,9 +334,9 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer([
new Document({
new Config.Document({
type: "document",
info: new Info({
info: new Config.Info({
compaction: new ConfigCompaction.Info({
buffer: 3_000,
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
+7 -8
View File
@@ -2,8 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
import { ConfigMedia } from "@opencode-ai/core/config/media"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
@@ -430,9 +429,9 @@ describe("ReadTool", () => {
}
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Document({
new Config.Document({
type: "document",
info: new Info({
info: new Config.Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
}),
@@ -473,9 +472,9 @@ describe("ReadTool", () => {
}
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Document({
new Config.Document({
type: "document",
info: new Info({
info: new Config.Info({
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
}),
}),
@@ -512,9 +511,9 @@ describe("ReadTool", () => {
}
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Document({
new Config.Document({
type: "document",
info: new Info({
info: new Config.Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
}),
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
TEffectHKT
>
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
export class SQLiteEffectSelectBuilder<
out TSelection extends SelectedFields | undefined,
out TRunResult,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TBuilderMode extends "db" | "qb" = "db",
TSelection extends SelectedFields | undefined,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TBuilderMode extends "db" | "qb" = "db",
> {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
}
}
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
export abstract class SQLiteEffectSession<
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
out TRunResult = unknown,
out TRelations extends AnyRelations = EmptyRelations,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TRunResult = unknown,
TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
}
export abstract class SQLiteEffectTransaction<
out TEffectHKT extends QueryEffectHKTBase,
out TRunResult,
out TRelations extends AnyRelations = EmptyRelations,
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
+10 -12
View File
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,25 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: Request
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
}
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHooks {
readonly context: SessionContext
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+9 -11
View File
@@ -15,25 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: Request
readonly use: (middleware: SessionHttpMiddleware) => void
}
export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly request: Request
response: Response
}
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHooks {
readonly context: SessionContext
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly http: SessionHttp
}
export type SessionDomain = Pick<
File diff suppressed because it is too large Load Diff
-3
View File
@@ -32,7 +32,6 @@ import { ProjectGroup } from "./groups/project.js"
import { ProjectCopyGroup } from "./groups/project-copy.js"
import { VcsGroup } from "./groups/vcs.js"
import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
@@ -54,7 +53,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
@@ -177,7 +175,6 @@ const makeApiFromGroup = <
.add(DebugGroup)
.add(MigrationGroup)
.add(WebSearchGroup.middleware(locationMiddleware))
.add(ConfigGroup.middleware(locationMiddleware))
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
-1
View File
@@ -62,7 +62,6 @@ export const groupNames = {
"server.project": "project",
"server.projectCopy": "projectCopy",
"server.vcs": "vcs",
"server.config": "config",
} as const
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
-22
View File
@@ -1,22 +0,0 @@
import { Config } from "@opencode-ai/schema/config"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const ConfigGroup = HttpApiGroup.make("server.config")
.add(
HttpApiEndpoint.get("config.get", "/api/config", {
query: LocationQuery,
success: Schema.Array(Config.Entry),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.config.get",
summary: "Get configuration",
description:
"Return configuration documents and discovery sources for the requested location, from lowest to highest priority.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "config", description: "Location-scoped configuration routes." }))
-136
View File
@@ -1,142 +1,6 @@
export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
import { ConfigCommand } from "./config/command.js"
import { ConfigExperimental } from "./config/experimental.js"
import { ConfigFormatter } from "./config/formatter.js"
import { ConfigLSP } from "./config/lsp.js"
import { ConfigMCP } from "./config/mcp.js"
import { ConfigModel } from "./config/model.js"
import { ConfigPlugin } from "./config/plugin.js"
import { ConfigProvider } from "./config/provider.js"
import { ConfigReference } from "./config/reference.js"
import { ConfigWebSearch } from "./config/websearch.js"
import { ConfigToolOutput } from "./config/tool-output.js"
import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(Schema.optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(Schema.optional),
})
.pipe(Schema.optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
export class Directory extends Schema.Class<Directory>("Config.Directory")({
type: Schema.Literal("directory"),
path: AbsolutePath,
}) {}
export class File extends Schema.Class<File>("Config.File")({
type: Schema.Literal("file"),
path: AbsolutePath,
}) {}
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
type: Schema.Literal("agents"),
path: AbsolutePath,
}) {}
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
type: Schema.Literal("claude"),
path: AbsolutePath,
}) {}
export const Entry = Schema.Union([Document, Directory, File, AgentsDirectory, ClaudeDirectory]).annotate({
identifier: "Config.Entry",
})
export type Entry = typeof Entry.Type
const Updated = ephemeral({
type: "config.updated",
+5 -1
View File
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth",
})
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Schema.toTaggedUnion("status"),
)
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
test("round-trips every configuration entry type", () => {
const entries = [
new Config.Document({
type: "document",
path: "/project/opencode.json",
info: new Config.Info({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
}),
}),
new Config.Document({ type: "document", info: new Config.Info({ shell: "/bin/zsh" }) }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/project/.opencode") }),
new Config.File({ type: "file", path: AbsolutePath.make("/project/opencode.json") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/project/.agents") }),
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/project/.claude") }),
]
const encoded = Schema.encodeSync(Schema.Array(Config.Entry))(entries)
const decoded = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(encoded)
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
})
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
})
-1
View File
@@ -5,7 +5,6 @@ export { ClientError } from "@opencode-ai/client/effect"
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
@@ -3,7 +3,6 @@ import { Location as CoreLocation } from "@opencode-ai/core/location"
import { SessionPending as CoreSessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
@@ -25,7 +24,6 @@ const CoreSession = await import("@opencode-ai/core/session")
test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent)
expect(SDK.Config).toBe(Config)
expect(SDK.Model).toBe(Model)
expect(SDK.WebSearch).toBe(WebSearch)
expect(SDK.Session).toBe(Session)
@@ -34,7 +32,6 @@ test("re-exports canonical contracts directly from Schema", () => {
"Agent",
"ClientError",
"Command",
"Config",
"Credential",
"FileSystem",
"Integration",
-2
View File
@@ -29,7 +29,6 @@ import { ProjectCopyHandler } from "./handlers/project-copy"
import { VcsHandler } from "./handlers/vcs"
import { EventFeed } from "./event-feed"
import { MigrationHandler } from "./handlers/migration"
import { ConfigHandler } from "./handlers/config"
export const handlers = Layer.mergeAll(
HealthHandler,
@@ -61,5 +60,4 @@ export const handlers = Layer.mergeAll(
ReferenceHandler,
ProjectCopyHandler,
VcsHandler,
ConfigHandler,
)
-7
View File
@@ -1,7 +0,0 @@
import { Config } from "@opencode-ai/core/config"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const ConfigHandler = HttpApiBuilder.group(Api, "server.config", (handlers) =>
handlers.handle("config.get", () => Config.Service.use((config) => config.entries())),
)
-62
View File
@@ -1,62 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("returns ordered config entries for the requested directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-config-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
fs.writeFile(
config,
JSON.stringify({
permissions: [
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: global },
fs: { filewatcher: false },
})
const url = new URL("/api/config", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", project)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
const document = entries.find(
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
)
expect(document?.info.permissions).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
+1 -1
View File
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined
}
+2 -6
View File
@@ -8,21 +8,17 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)

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