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
138 changed files with 957 additions and 7175 deletions
+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(
+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,
),
},
}
}
@@ -1698,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 = {
@@ -4767,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")
+1 -1
View File
@@ -11,7 +11,7 @@
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b && tsgo --noEmit -p tsconfig.tests.json"
"typecheck": "tsgo --noEmit"
},
"bin": {
"opencode": "./bin/opencode"
+6 -10
View File
@@ -132,16 +132,14 @@ function renderMigration(name: string, sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: ${JSON.stringify(name)},
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
`
}
@@ -149,15 +147,13 @@ function renderSchema(sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
const schema: Omit<DatabaseMigration.Migration, "id"> = {
export default {
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
})
},
}
export default schema
} satisfies Omit<DatabaseMigration.Migration, "id">
`
}
@@ -195,10 +191,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
export const migrations: DatabaseMigration.Migration[] = (
export const migrations = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
`
}
+147 -21
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,
@@ -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,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { DatabaseMigration } from "./migration"
export const migrations: DatabaseMigration.Migration[] = (
export const migrations = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
@@ -43,4 +43,4 @@ export const migrations: DatabaseMigration.Migration[] = (
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
])
).map((module) => module.default)
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260127222353_familiar_lady_ursula",
up(tx) {
return Effect.gen(function* () {
@@ -104,6 +104,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260211171708_add_project_commands",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260213144116_wakeful_the_professor",
up(tx) {
return Effect.gen(function* () {
@@ -20,6 +20,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260225215848_workspace",
up(tx) {
return Effect.gen(function* () {
@@ -16,6 +16,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260227213759_add_session_workspace_id",
up(tx) {
return Effect.gen(function* () {
@@ -9,6 +9,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260228203230_blue_harpoon",
up(tx) {
return Effect.gen(function* () {
@@ -27,6 +27,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260303231226_add_workspace_fields",
up(tx) {
return Effect.gen(function* () {
@@ -12,6 +12,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260309230000_move_org_to_state",
up(tx) {
return Effect.gen(function* () {
@@ -12,6 +12,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260312043431_session_message_cursor",
up(tx) {
return Effect.gen(function* () {
@@ -13,6 +13,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260323234822_events",
up(tx) {
return Effect.gen(function* () {
@@ -23,6 +23,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
@@ -26,6 +26,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260413175956_chief_energizer",
up(tx) {
return Effect.gen(function* () {
@@ -21,6 +21,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260423070820_add_icon_url_override",
up(tx) {
return Effect.gen(function* () {
@@ -11,6 +11,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260427172553_slow_nightmare",
up(tx) {
return Effect.gen(function* () {
@@ -27,6 +27,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DROP TABLE \`session_entry\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260428004200_add_session_path",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260501142318_next_venus",
up(tx) {
return Effect.gen(function* () {
@@ -9,6 +9,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260504145000_add_sync_owner",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260507164347_add_workspace_time",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260510033149_session_usage",
up(tx) {
return Effect.gen(function* () {
@@ -53,6 +53,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260511000411_data_migration_state",
up(tx) {
return Effect.gen(function* () {
@@ -13,6 +13,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260511173437_session-metadata",
up(tx) {
return Effect.gen(function* () {
@@ -13,6 +13,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260601010001_normalize_storage_paths",
up(tx) {
return Effect.gen(function* () {
@@ -19,6 +19,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260601202201_amazing_prowler",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`permission\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260602002951_lowly_union_jack",
up(tx) {
return Effect.gen(function* () {
@@ -21,6 +21,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260602182828_add_project_directories",
up(tx) {
return Effect.gen(function* () {
@@ -17,6 +17,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603001617_session_message_projection_indexes",
up(tx) {
return Effect.gen(function* () {
@@ -16,6 +16,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603040000_session_message_projection_order",
up(tx) {
return Effect.gen(function* () {
@@ -16,6 +16,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603141458_session_input_inbox",
up(tx) {
return Effect.gen(function* () {
@@ -22,6 +22,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260603160727_jittery_ezekiel_stane",
up(tx) {
return Effect.gen(function* () {
@@ -17,6 +17,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260604172448_event_sourced_session_input",
up(tx) {
return Effect.gen(function* () {
@@ -44,6 +44,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260605003541_add_session_context_snapshot",
up(tx) {
return Effect.gen(function* () {
@@ -18,6 +18,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,13 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260605042240_add_context_epoch_agent",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`agent\` text DEFAULT 'build' NOT NULL;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260611035744_credential",
up(tx) {
return Effect.gen(function* () {
@@ -22,6 +22,4 @@ const migration: DatabaseMigration.Migration = {
)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260611192811_lush_chimera",
up(tx) {
return Effect.gen(function* () {
@@ -22,6 +22,4 @@ const migration: DatabaseMigration.Migration = {
`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260612174303_project_dir_strategy",
up(tx) {
return Effect.gen(function* () {
@@ -26,6 +26,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
@@ -10,6 +10,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
@@ -12,6 +12,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
@@ -14,6 +14,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
@@ -135,6 +135,4 @@ const migration: DatabaseMigration.Migration = {
yield* tx.run(`DROP TABLE \`session_input\`;`)
})
},
}
export default migration
} satisfies DatabaseMigration.Migration
@@ -30,14 +30,12 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(LegacyValue)
const wellKnownSourcesKey = "wellknown:sources"
const migration: DatabaseMigration.Migration = {
export default {
id: "20260805200742_import_legacy_credentials",
up(tx) {
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
},
}
export default migration
} satisfies DatabaseMigration.Migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
+2 -4
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
const schema: Omit<DatabaseMigration.Migration, "id"> = {
export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
@@ -248,6 +248,4 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
)
})
},
}
export default schema
} satisfies Omit<DatabaseMigration.Migration, "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)
+6 -6
View File
@@ -82,10 +82,10 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
}
}
export const empty: ReadonlyArray<Source> = []
export const empty: Instructions = []
/** Closes a typed definition into one `Source`, so differently typed sources compose. */
export function make<A>(source: Source.Definition<A>): ReadonlyArray<Source> {
export function make<A>(source: Source.Definition<A>): Instructions {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
@@ -121,7 +121,7 @@ export function make<A>(source: Source.Definition<A>): ReadonlyArray<Source> {
]
}
export function combine(values: ReadonlyArray<ReadonlyArray<Source>>): ReadonlyArray<Source> {
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flat()
const keys = new Set<Key>()
for (const source of sources) {
@@ -131,7 +131,7 @@ export function combine(values: ReadonlyArray<ReadonlyArray<Source>>): ReadonlyA
return sources
}
export function read(value: ReadonlyArray<Source>): Effect.Effect<ReadResult> {
export function read(value: Instructions): Effect.Effect<ReadResult> {
return Effect.forEach(
value,
(source) => source.read.pipe(Effect.map((observed) => ({ key: source.key, value: observed }))),
@@ -158,7 +158,7 @@ export function diff(observed: ReadResult, previous?: Values): Effect.Effect<Adm
return Effect.succeed({ delta, blobs })
}
export function renderInitial(value: ReadonlyArray<Source>, values: Readonly<Record<string, Schema.Json>>) {
export function renderInitial(value: Instructions, values: Readonly<Record<string, Schema.Json>>) {
return render(
value.flatMap((source) => {
if (!Object.hasOwn(values, source.key)) return []
@@ -169,7 +169,7 @@ export function renderInitial(value: ReadonlyArray<Source>, values: Readonly<Rec
}
export function renderUpdate(
value: ReadonlyArray<Source>,
value: Instructions,
previous: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
) {
+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"
@@ -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)
}),
)
}),
+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) {
+2 -3
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"
@@ -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,
+44 -48
View File
@@ -13,13 +13,54 @@ export abstract class NamedError extends Error {
static create<Name extends string, Fields extends Schema.Struct.Fields>(
name: Name,
fields: Fields,
): ReturnType<typeof createSchemaClass<Name, Schema.Struct<Fields>>>
): ReturnType<typeof NamedError.createSchemaClass<Name, Schema.Struct<Fields>>>
static create<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
): ReturnType<typeof createSchemaClass<Name, DataSchema>>
): ReturnType<typeof NamedError.createSchemaClass<Name, DataSchema>>
static create<Name extends string>(name: Name, data: Schema.Top | Schema.Struct.Fields) {
return createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data), this)
return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data))
}
private static createSchemaClass<Name extends string, DataSchema extends Schema.Top>(name: Name, data: DataSchema) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends NamedError {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name
constructor(
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: unknown): input is InstanceType<typeof result> {
return NamedError.hasName(input, name)
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
}
public static readonly Unknown = NamedError.create("UnknownError", {
@@ -27,48 +68,3 @@ export abstract class NamedError extends Error {
ref: Schema.optional(Schema.String),
})
}
function createSchemaClass<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
base: typeof NamedError = NamedError,
) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends base {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name
constructor(
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: unknown): input is InstanceType<typeof result> {
return base.hasName(input, name)
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
}

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