Compare commits

..

4 Commits

Author SHA1 Message Date
James Long ff736fbbc8 feat(tui): coalesce equivalent forms 2026-08-06 17:48:30 +00:00
Dax 1539bd6794 fix(client): evict unresponsive service (#40896) 2026-08-06 10:37:05 -07:00
Dax 939da6a5e7 feat(cli): add debug config command (#40895) 2026-08-06 10:35:48 -07:00
Dax Raad 0f27ee7b4c fix(cli): reuse server for noninteractive commands 2026-08-06 12:57:37 -04:00
106 changed files with 7350 additions and 825 deletions
-1
View File
@@ -513,7 +513,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@opencode-ai/schema": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
+4 -1
View File
@@ -68,7 +68,10 @@ 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" })],
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "Show resolved configuration" }),
],
}),
Spec.make("console", {
description: "Manage OpenCode Console access",
@@ -0,0 +1,19 @@
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,6 +22,7 @@ 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(
+5 -1
View File
@@ -10,7 +10,11 @@ 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 })
const server = yield* ServerConnection.resolve({
server: serverURL,
standalone: input.standalone,
mismatch: "replace",
})
const config = yield* Config.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
+1
View File
@@ -22,6 +22,7 @@ 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,9 +42,10 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
const options = yield* ServiceConfig.options()
const mismatch = args.mismatch ?? "ignore"
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
return {
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
service: managedService(options),
} satisfies Resolved
})
+2 -2
View File
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
}
})
export const options = Effect.fnUntraced(function* () {
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
const { file, legacyRegistrationFiles } = yield* paths
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: OPENCODE_VERSION,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
command: [...selfCommand(), "serve", "--service"],
}
})
+83
View File
@@ -0,0 +1,83 @@
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,3 +55,17 @@ 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,6 +8,7 @@ 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"
@@ -48,6 +49,7 @@ 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,6 +38,7 @@ 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>
@@ -1595,6 +1596,16 @@ 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>
@@ -1625,4 +1636,5 @@ export interface AppApi<E = never> {
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
readonly websearch: WebsearchApi<E>
readonly config: ConfigApi<E>
}
@@ -220,6 +220,8 @@ import type {
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js"
import { ClientError } from "./client-error"
@@ -1241,6 +1243,13 @@ 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"]),
@@ -1271,6 +1280,7 @@ 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,6 +8,7 @@ export type {
AppApi,
CatalogApi,
CommandApi,
ConfigApi,
EventApi,
IntegrationApi,
ModelApi,
@@ -20,6 +21,7 @@ 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"
+62 -18
View File
@@ -53,6 +53,7 @@ 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
@@ -82,6 +83,18 @@ 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)
@@ -182,6 +195,10 @@ 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:
@@ -189,39 +206,53 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const response = yield* Effect.tryPromise(() =>
const signal = AbortSignal.timeout(2_000)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
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))
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
const health = decodeHealth(body)
if (Option.isSome(health)) {
if (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined
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 }
return {
info,
endpoint,
version: health.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService
service: {
info,
endpoint,
version: health.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService,
timedOut: false,
}
}
if (
!allowLegacy ||
Option.isNone(decodeLegacyHealth(body)) ||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
)
return undefined
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
}
})
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
const info = yield* read(file)
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: yield* probe(info, allowLegacy) }
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(yield* probeResult(info, allowLegacy)) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
@@ -249,6 +280,19 @@ 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,6 +2,7 @@ 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,6 +216,8 @@ import type {
WebsearchProvidersOutput,
WebsearchQueryInput,
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -1811,6 +1813,20 @@ 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,
),
},
}
}
+198 -2
View File
@@ -1698,6 +1698,180 @@ 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 = {
@@ -1763,9 +1937,23 @@ export type IntegrationInfo = {
connections: Array<ConnectionInfo>
}
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
export type FormInfo = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata
fields: FormFields
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
export type FormInfo1 = {
id: string
sessionID: string
title: string
coalesce?: string
metadata?: FormMetadata1
fields: FormFields1
}
export type SessionInputAdmitted = {
id: string
@@ -4593,3 +4781,11 @@ 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,6 +3,7 @@ export type {
AgentApi,
CatalogApi,
CommandApi,
ConfigApi,
EventApi,
IntegrationApi,
ModelApi,
+63 -15
View File
@@ -34,6 +34,7 @@ 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
@@ -62,6 +63,19 @@ 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
@@ -145,6 +159,10 @@ 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:
@@ -152,30 +170,48 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const response = await fetch(new URL("/api/health", info.url), {
const signal = AbortSignal.timeout(2_000)
const result = await fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
signal: AbortSignal.timeout(2_000),
}).catch(() => undefined)
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
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
if (body !== undefined && "version" in body && "pid" in body) {
if (body.pid !== info.pid) return undefined
if (info.version !== undefined && body.version !== info.version) return undefined
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && body.version !== info.version)
return { service: undefined, timedOut: false }
return {
info,
endpoint,
version: body.version,
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
legacy: false,
service: {
info,
endpoint,
version: body.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService,
timedOut: false,
}
}
if (!allowLegacy || body?.healthy !== true) return undefined
return { info, endpoint, state: "ready", legacy: true }
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
}
}
async function registered(file?: string, allowLegacy = false) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: await probe(info, allowLegacy) }
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(await probeResult(info, allowLegacy)) }
}
async function find(options: { readonly file?: string }) {
@@ -209,6 +245,18 @@ 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,6 +1,7 @@
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"
@@ -10,6 +11,7 @@ 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,6 +47,10 @@ 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,6 +70,32 @@ 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,6 +33,7 @@ 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"])
@@ -50,6 +51,34 @@ 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,6 +70,30 @@ 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")
+21 -147
View File
@@ -5,8 +5,16 @@ 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 { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import {
AgentsDirectory,
ClaudeDirectory,
Directory,
Document,
File,
Info,
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { Bus } from "./bus"
@@ -15,141 +23,11 @@ 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")
@@ -296,21 +174,17 @@ export const layer = (options?: Options) => Layer.effect(
// We load certain files from a few other folders in the ecosystem
const claude = [
...((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) })),
]
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude"),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...((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) })),
]
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents"),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const directories = [
globalDirectory,
@@ -408,7 +282,7 @@ export const layer = (options?: Options) => Layer.effect(
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* bus.publish(Event.Updated, {})
}),
),
)
+7 -6
View File
@@ -1,11 +1,12 @@
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"
@@ -24,7 +25,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(Config.Info)
const decodeConfig = Schema.decodeUnknownOption(Info)
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
@@ -63,13 +64,13 @@ export const Plugin = define({
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
documents.filter((document): document is Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Config.Document[] }
const loaded = { documents: [] as Document[] }
const reload = load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
@@ -139,7 +140,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: Config.Entry[], file: string) {
function isAgentSource(entries: Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
@@ -208,5 +209,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
}),
)
if (!info) return
return new Config.Document({ type: "document", path: file.filepath, info })
return new Document({ type: "document", path: file.filepath, info })
}
+4 -3
View File
@@ -1,12 +1,13 @@
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)
@@ -27,7 +28,7 @@ export const Plugin = define({
)
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
const loaded = { documents: [] as { commands: Info["commands"] }[] }
const reload = load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
@@ -75,7 +76,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: Config.Entry[], file: string) {
function isCommandSource(entries: Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
+2 -1
View File
@@ -1,6 +1,7 @@
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"
@@ -13,7 +14,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 Config.Document => entry.type === "document")
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
+3 -2
View File
@@ -1,6 +1,7 @@
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"
@@ -107,8 +108,8 @@ export const Plugin = define({
}),
})
function configuredProviders(entries: readonly Config.Entry[]) {
function configuredProviders(entries: readonly Entry[]) {
return entries
.filter((entry): entry is Config.Document => entry.type === "document")
.filter((entry): entry is Document => entry.type === "document")
.flatMap((file) => Object.entries(file.info.providers ?? {}))
}
+3 -2
View File
@@ -1,10 +1,11 @@
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"
@@ -19,7 +20,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 Config.Document => entry.type === "document")) {
for (const doc of loaded.entries.filter((entry): entry is 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
@@ -3,6 +3,7 @@ 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"
@@ -41,7 +42,7 @@ const layer = Layer.effect(
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = Protected.isHome(location.directory)
+1
View File
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
id,
sessionID: input.sessionID,
title: input.title,
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields,
}
+49
View File
@@ -0,0 +1,49 @@
import { create } from "@opencode-ai/schema/identifier"
const prefixes = {
job: "job",
event: "evt",
session: "ses",
message: "msg",
permission: "per",
question: "que",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return createID(prefixes[prefix], direction)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
return prefix + "_" + create(direction === "descending", timestamp)
}
export { createID as create }
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
export * as Identifier from "./id"
+2 -2
View File
@@ -1,8 +1,8 @@
export * as Job from "./job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { JobID } from "@opencode-ai/schema/job-id"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Identifier } from "./id/id"
import { SessionSchema } from "./session/schema"
export type Status = "running" | "completed" | "error" | "cancelled"
@@ -202,7 +202,7 @@ export const make = Effect.gen(function* () {
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? JobID.create()
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const backgrounded = yield* Deferred.make<Info>()
+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 "../config/mcp"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
+3 -2
View File
@@ -3,11 +3,12 @@ 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"
@@ -178,7 +179,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 Config.Document => entry.type === "document")
const documents = (yield* config.entries()).filter((entry): entry is 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 "../config/mcp"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { OauthCallbackPage } from "../oauth/page"
import type { Integration } from "../integration"
+7 -7
View File
@@ -1,7 +1,8 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
@@ -9,7 +10,6 @@ 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 Config.Entry[]) {
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Config.Document => entry.type === "document")
.filter((entry): entry is 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 Config.Entry[], file: string) {
function isPluginSource(entries: readonly 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 Config.Entry[],
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
+2 -2
View File
@@ -92,7 +92,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
function isError(error: unknown): error is Error {
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
@@ -104,7 +104,7 @@ function isError(error: unknown): error is Error {
)
}
const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
return yield* Effect.try({
try: () => Repository.validateBranch(branch),
catch: (error) => new InvalidBranchError({ branch, message: errorMessage(error) }),
+3 -2
View File
@@ -2,6 +2,7 @@ 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"
@@ -148,9 +149,9 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const settings = (documents: readonly Config.Entry[]) => {
const settings = (documents: readonly Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
@@ -59,6 +59,7 @@ export const Plugin = {
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
coalesce: `${context.messageID}:websearch-consent`,
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -91,6 +92,7 @@ export const Plugin = {
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
coalesce: `${context.messageID}:websearch-provider`,
metadata: { kind: "websearch.provider" },
fields: [
{
+1
View File
@@ -0,0 +1 @@
export * as Identifier from "@opencode-ai/schema/identifier"
+8
View File
@@ -21,3 +21,11 @@ export function getFilenameTruncated(path: string | undefined, maxLength: number
if (available <= 0) return filename.slice(0, maxLength - 1) + "…"
return filename.slice(0, available) + "…" + ext
}
export function truncateMiddle(text: string, maxLength: number = 20) {
if (text.length <= maxLength) return text
const available = maxLength - 1 // -1 for ellipsis
const start = Math.ceil(available / 2)
const end = Math.floor(available / 2)
return text.slice(0, start) + "…" + text.slice(-end)
}
+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"
+10 -9
View File
@@ -5,6 +5,7 @@ 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"
@@ -19,7 +20,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(Config.Info)
const decode = Schema.decodeUnknownSync(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" },
@@ -71,7 +72,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
@@ -92,7 +93,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
},
}),
}),
new Config.Document({
new Document({
type: "document",
info: decode({
permissions: [{ action: "read", resource: "*", effect: "allow" }],
@@ -153,7 +154,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.gen(function* () {
const agents = yield* Agent.Service
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
agents: {
@@ -173,7 +174,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
},
}),
}),
new Config.Document({
new Document({
type: "document",
info: decode({
agents: {
@@ -218,7 +219,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
yield* agents.transform((editor) => editor.update(build, () => {}))
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({ agents: { build: { disabled: true } } }),
}),
@@ -276,7 +277,7 @@ Use native v2 fields.`,
const agents = yield* Agent.Service
const global = yield* Global.Service
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
}),
@@ -425,7 +426,7 @@ Use native v2 fields.`,
})
function directoryEntry(directory: string) {
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
function sourceCases() {
@@ -522,7 +523,7 @@ function loadHomePermissions(home: string) {
const build = Agent.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const entries = [
new Config.Document({
new 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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Directory, Document, Event, Info } 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(Config.Info)
const decode = Schema.decodeUnknownSync(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(ConfigSchema.Event.Updated, {})
const update = yield* bus.publish(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 Config.Document({
new Document({
type: "document",
info: decode({ commands: { review: { template: "Inline review" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
new 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 Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
function sourceCases() {
+51 -19
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 { 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 { 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 { 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(ConfigSchema.Event.Updated)
.subscribe(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(ConfigSchema.Event.Updated)
.subscribe(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(ConfigSchema.Event.Updated)
.subscribe(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 Config.Document({ type: "document", info: new Config.Info({}) })
const entry = new Document({ type: "document", info: new 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 Config.Document({
new Document({
type: "document",
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
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({
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({
type: "document",
info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
info: new 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(ConfigSchema.Event.Updated)
.subscribe(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(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
@@ -661,13 +661,45 @@ describe("Config", () => {
const entries = yield* config.entries()
expect(entries).toEqual([
new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
new 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()),
@@ -729,7 +761,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(Config.Document)
expect(documents[0]).toBeInstanceOf(Document)
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
@@ -1177,7 +1209,7 @@ describe("Config", () => {
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents).toHaveLength(1)
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
expect(documents[0]?.info).toBeInstanceOf(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/core/config/model"
import { ConfigModel } from "@opencode-ai/schema/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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Document, Event, Info, type Entry } 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(Config.Info)
const decode = Schema.decodeUnknownSync(Info)
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
new Config.Document({
new Document({
type: "document",
info: decode({
experimental: {
@@ -24,7 +24,7 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
}),
})
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
const addPlugin = Effect.fn(function* (entries: 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(ConfigSchema.Event.Updated, {})
yield* bus.publish(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" })]))),
)
+11 -10
View File
@@ -1,5 +1,6 @@
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"
@@ -14,7 +15,7 @@ import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
@@ -46,7 +47,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
)
}
const decode = Schema.decodeUnknownSync(Config.Info)
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("defaults custom models to agent capabilities", () =>
@@ -55,7 +56,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
providers: {
@@ -90,7 +91,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
})
})
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
providers: {
@@ -129,7 +130,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
providers: {
@@ -178,7 +179,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
providers: {
@@ -189,7 +190,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Config.Document({
new Document({
type: "document",
info: decode({
providers: {
@@ -223,7 +224,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const entries = [
new Config.Document({
new Document({
type: "document",
info: decode({
model: "custom/first",
@@ -255,7 +256,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Config.Document({
new Document({
type: "document",
info: decode({
model: "custom/default",
@@ -289,7 +290,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Config.Document({
new 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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Document, Event, Info } 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(Config.Info)
const decode = Schema.decodeUnknownSync(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(ConfigSchema.Event.Updated, {})
yield* bus.publish(Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
return (
@@ -83,7 +83,7 @@ describe("config plugin reloads", () => {
})
function config(name: string) {
return new Config.Document({
return new Document({
type: "document",
path: document,
info: decode({
+6 -5
View File
@@ -2,6 +2,7 @@ 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"
@@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(Layer.empty)
const decode = Schema.decodeUnknownSync(Config.Info)
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigSkillPlugin.Plugin", () => {
it.effect("registers configured skill directories and URLs", () =>
@@ -43,10 +44,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
Effect.provide(
Config.testLayer([
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({
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({
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 { Config } from "../../src/config"
import { Info } from "@opencode-ai/schema/config"
const decode = Schema.decodeUnknownSync(Config.Info)
const decode = Schema.decodeUnknownSync(Info)
describe("config warming", () => {
test("accepts boolean enablement", () => {
+2
View File
@@ -15,6 +15,7 @@ const input = {
id: formID,
sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form",
coalesce: "test-form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
@@ -32,6 +33,7 @@ describe("Form", () => {
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.coalesce).toBe("test-form")
yield* service.cancel(form.id)
+4 -3
View File
@@ -5,6 +5,7 @@ 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"
@@ -13,16 +14,16 @@ import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
type ConfigInput = typeof Config.Info.Encoded
type ConfigInput = typeof Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const entries =
configured === undefined
? []
: [
new Config.Document({
new Document({
type: "document",
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
}),
]
return AppNodeBuilder.build(Formatter.node, [
+4 -3
View File
@@ -12,7 +12,8 @@ import {
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/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"
@@ -162,9 +163,9 @@ function resourceMcpLayer(
Layer.provide(
Layer.mergeAll(
Config.testLayer([
new Config.Document({
new Document({
type: "document",
info: new Config.Info({
info: new Info({
mcp: new ConfigMCP.Info({
servers: {
resources:
+2 -1
View File
@@ -1,6 +1,7 @@
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"
@@ -213,7 +214,7 @@ const configuredIt = testEffect(
entries: () =>
Effect.succeed(
configuredShell
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })]
: [],
),
}),
@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
+6 -5
View File
@@ -49,9 +49,10 @@ 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 { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
import {
InstructionStateTable,
SessionPendingTable,
@@ -228,7 +229,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
)
@@ -334,9 +335,9 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer([
new Config.Document({
new Document({
type: "document",
info: new Config.Info({
info: new Info({
compaction: new ConfigCompaction.Info({
buffer: 3_000,
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
+1 -1
View File
@@ -23,7 +23,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { Pty } from "@opencode-ai/schema/pty"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath, optional, statics } from "@opencode-ai/schema/schema"
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
test("Core reuses the canonical shared schemas", async () => {
const schemaAgent = await import("@opencode-ai/schema/agent")
+8 -7
View File
@@ -2,7 +2,8 @@ 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 { ConfigMedia } from "@opencode-ai/core/config/media"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/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"
@@ -429,9 +430,9 @@ describe("ReadTool", () => {
}
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Config.Document({
new Document({
type: "document",
info: new Config.Info({
info: new Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
}),
@@ -472,9 +473,9 @@ describe("ReadTool", () => {
}
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Config.Document({
new Document({
type: "document",
info: new Config.Info({
info: new Info({
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
}),
}),
@@ -511,9 +512,9 @@ describe("ReadTool", () => {
}
const configTest = yield* Config.Test
yield* configTest.setEntries([
new Config.Document({
new Document({
type: "document",
info: new Config.Info({
info: new Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
}),
@@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => {
{
sessionID,
title: "Web Search",
coalesce: "msg_tool_test:websearch-consent",
metadata: { kind: "websearch.provider" },
fields: [
{
@@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => {
expect(formRequests[1]).toEqual({
sessionID,
title: "Choose a web search provider",
coalesce: "msg_tool_test:websearch-provider",
metadata: { kind: "websearch.provider" },
fields: [
{
+12
View File
@@ -0,0 +1,12 @@
import { $ } from "bun"
import * as path from "node:path"
import { RUST_TARGET } from "./utils"
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
const BUNDLE_DIR = "dist"
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
await $`mkdir -p ${BUNDLES_OUT_DIR}`
await $`cp -r ${BUNDLE_DIR}/* ${BUNDLES_OUT_DIR}`
-1
View File
@@ -33,7 +33,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@opencode-ai/schema": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@typescript/native-preview": "catalog:",
"@types/bun": "catalog:",
+14 -15
View File
@@ -1,12 +1,11 @@
import { describe, expect, test } from "bun:test"
import { descending } from "@opencode-ai/schema/identifier"
import { SessionID } from "@opencode-ai/schema/session-id"
import { Share } from "../../src/core/share"
import { Storage } from "../../src/core/storage"
import { Identifier } from "@opencode-ai/core/util/identifier"
describe.concurrent("core.share", () => {
test("should create a share", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
expect(share.sessionID).toBe(sessionID)
@@ -16,7 +15,7 @@ describe.concurrent("core.share", () => {
})
test("should remove a share as admin", async () => {
const share = await Share.create({ sessionID: SessionID.create() })
const share = await Share.create({ sessionID: Identifier.descending() })
await Share.removeAdmin({ id: share.id })
@@ -24,7 +23,7 @@ describe.concurrent("core.share", () => {
})
test("should sync data to a share", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
@@ -46,7 +45,7 @@ describe.concurrent("core.share", () => {
})
test("should sync multiple batches of data", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data1: Share.Data[] = [
@@ -80,7 +79,7 @@ describe.concurrent("core.share", () => {
})
test("should retrieve synced data", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
@@ -109,7 +108,7 @@ describe.concurrent("core.share", () => {
})
test("should retrieve data from multiple syncs", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data1: Share.Data[] = [
@@ -155,7 +154,7 @@ describe.concurrent("core.share", () => {
})
test("should return latest data when syncing duplicate parts", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data1: Share.Data[] = [
@@ -193,7 +192,7 @@ describe.concurrent("core.share", () => {
})
test("should return empty array for share with no data", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const result = await Share.data(share.id)
@@ -204,7 +203,7 @@ describe.concurrent("core.share", () => {
})
test("should migrate legacy event data into the snapshot", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
{
@@ -214,7 +213,7 @@ describe.concurrent("core.share", () => {
]
await Storage.remove(["share_snapshot", share.id])
await Storage.write(["share_event", share.id, descending()], data)
await Storage.write(["share_event", share.id, Identifier.descending()], data)
const result = await Share.data(share.id)
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
@@ -226,7 +225,7 @@ describe.concurrent("core.share", () => {
})
test("should throw error for invalid secret", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
@@ -247,7 +246,7 @@ describe.concurrent("core.share", () => {
})
test("should throw error for non-existent share", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const data: Share.Data[] = [
{
type: "part",
@@ -264,7 +263,7 @@ describe.concurrent("core.share", () => {
})
test("should handle different data types", async () => {
const sessionID = SessionID.create()
const sessionID = Identifier.descending()
const share = await Share.create({ sessionID })
const data: Share.Data[] = [
File diff suppressed because it is too large Load Diff
+3
View File
@@ -32,6 +32,7 @@ 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>
@@ -53,6 +54,7 @@ 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>>
@@ -175,6 +177,7 @@ const makeApiFromGroup = <
.add(DebugGroup)
.add(MigrationGroup)
.add(WebSearchGroup.middleware(locationMiddleware))
.add(ConfigGroup.middleware(locationMiddleware))
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
+1
View File
@@ -62,6 +62,7 @@ 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
@@ -0,0 +1,22 @@
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." }))
+1 -1
View File
@@ -115,7 +115,7 @@ const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
})
const SessionsQuery = Schema.Struct({
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
+136
View File
@@ -1,6 +1,142 @@
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",
@@ -1,10 +1,10 @@
export * as ConfigAgent from "./agent"
export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigProvider } from "./provider"
import { ConfigModel } from "./model"
import { PositiveInt } from "../schema"
import { Permission } from "../permission.js"
import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
@@ -1,7 +1,7 @@
export * as ConfigCommand from "./command"
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { ConfigModel } from "./model"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
@@ -1,7 +1,7 @@
export * as ConfigCompaction from "./compaction"
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
import { NonNegativeInt } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(Schema.optional),
@@ -1,8 +1,8 @@
export * as ConfigExperimental from "./experimental"
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
import { ConfigPolicy } from "./policy"
import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
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"
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigLSP from "./lsp"
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
@@ -1,10 +1,8 @@
export * as ConfigMCP from "./mcp"
export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Mcp } from "../mcp.js"
// 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"
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
import { PositiveInt } from "../schema.js"
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"
export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -1,4 +1,4 @@
export * as ConfigPlugin from "./plugin"
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigPolicy from "./policy"
export * as ConfigPolicy from "./policy.js"
import { Schema } from "effect"
@@ -1,8 +1,8 @@
export * as ConfigProvider from "./provider"
export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
@@ -1,4 +1,4 @@
export * as ConfigReference from "./reference"
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
@@ -1,7 +1,7 @@
export * as ConfigToolOutput from "./tool-output"
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
import { PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(Schema.optional),
@@ -1,4 +1,4 @@
export * as ConfigWarming from "./warming"
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigWatcher from "./watcher"
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
@@ -1,7 +1,7 @@
export * as ConfigWebSearch from "./websearch"
export * as ConfigWebSearch from "./websearch.js"
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
View File
@@ -124,6 +124,9 @@ const InfoBase = {
// on non-session owners anywhere else.
sessionID: Schema.String,
title: Schema.String,
coalesce: Schema.String.pipe(optional).annotate({
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
}),
metadata: Metadata.pipe(optional),
}
-9
View File
@@ -1,9 +0,0 @@
import { Schema } from "effect"
import { ascending } from "./identifier.js"
import { statics } from "./schema.js"
export const JobID = Schema.String.check(Schema.isStartsWith("job_")).pipe(
Schema.brand("JobID"),
statics((schema) => ({ create: () => schema.make("job_" + ascending()) })),
)
export type JobID = typeof JobID.Type
+42
View File
@@ -0,0 +1,42 @@
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")
})
})
@@ -3,7 +3,6 @@ import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js"
import { FileSystem } from "../src/filesystem.js"
import { Form } from "../src/form.js"
import { JobID } from "../src/job-id.js"
import { Mcp } from "../src/mcp.js"
import { Model } from "../src/model.js"
import { Project } from "../src/project.js"
@@ -132,7 +131,6 @@ describe("contract hygiene", () => {
})
test("current ID constructors expose create", () => {
expect(JobID.create()).toStartWith("job_")
expect(Question.ID.create()).toStartWith("que_")
expect(Pty.ID.create()).toStartWith("pty_")
})
+1
View File
@@ -5,6 +5,7 @@ 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,6 +3,7 @@ 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"
@@ -24,6 +25,7 @@ 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)
@@ -32,6 +34,7 @@ test("re-exports canonical contracts directly from Schema", () => {
"Agent",
"ClientError",
"Command",
"Config",
"Credential",
"FileSystem",
"Integration",
+2
View File
@@ -29,6 +29,7 @@ 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,
@@ -60,4 +61,5 @@ export const handlers = Layer.mergeAll(
ReferenceHandler,
ProjectCopyHandler,
VcsHandler,
ConfigHandler,
)
+7
View File
@@ -0,0 +1,7 @@
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())),
)
@@ -2,6 +2,7 @@ import { Effect } from "effect"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError } from "@opencode-ai/protocol/errors"
import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
const REASON_LIMIT = 1024
+62
View File
@@ -0,0 +1,62 @@
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]()),
),
)
+307
View File
@@ -0,0 +1,307 @@
[14:25:48.462] [INFO] storybook v10.2.10
[14:25:48.749] [DEBUG] Getting package.json info for /Users/davidhill/Documents/Local/opencode/packages/storybook/package.json...
[14:25:48.997] [INFO] Starting...
[14:25:49.095] [DEBUG] Starting preview..
[14:25:49.098] [WARN] 🚨 Unable to index files:
- ./../ui/src/components/accordion.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/accordion.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/app-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/app-icon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/avatar.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/avatar.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/basic-tool.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/basic-tool.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/checkbox.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/checkbox.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/code.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/code.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/collapsible.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/collapsible.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/context-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/context-menu.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/dialog.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dialog.stories.tsx (line 10, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/diff-changes.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-changes.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/diff-ssr.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-ssr.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/diff.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/dock-prompt.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dock-prompt.stories.tsx (line 15, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/dropdown-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dropdown-menu.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/favicon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/favicon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/file-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/file-icon.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/font.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/font.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/hover-card.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/hover-card.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/icon-button.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon-button.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/image-preview.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/image-preview.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/inline-input.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/inline-input.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/keybind.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/keybind.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/line-comment.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/line-comment.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/list.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/list.stories.tsx (line 15, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/logo.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/logo.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/markdown.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/markdown.stories.tsx (line 12, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/message-nav.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-nav.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/message-part.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-part.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/popover.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/popover.stories.tsx (line 16, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/progress-circle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress-circle.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/progress.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress.stories.tsx (line 15, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/provider-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/provider-icon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/radio-group.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/radio-group.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/resize-handle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/resize-handle.stories.tsx (line 17, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/select.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/select.stories.tsx (line 16, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/session-review.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-review.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/session-turn.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/spinner.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/spinner.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/sticky-accordion-header.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/sticky-accordion-header.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/switch.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/switch.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/tabs.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tabs.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/tag.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tag.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/text-field.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-field.stories.tsx (line 14, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/text-shimmer.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-shimmer.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/toast.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/toast.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/tooltip.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tooltip.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/typewriter.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/typewriter.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
[14:25:49.109] [ERROR] Failed to build the preview
[14:25:49.110] [ERROR] Error: Unable to index files:
- ./../ui/src/components/accordion.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/accordion.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/app-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/app-icon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/avatar.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/avatar.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/basic-tool.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/basic-tool.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/checkbox.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/checkbox.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/code.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/code.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/collapsible.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/collapsible.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/context-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/context-menu.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/dialog.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dialog.stories.tsx (line 10, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/diff-changes.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-changes.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/diff-ssr.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-ssr.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/diff.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/dock-prompt.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dock-prompt.stories.tsx (line 15, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/dropdown-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dropdown-menu.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/favicon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/favicon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/file-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/file-icon.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/font.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/font.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/hover-card.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/hover-card.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/icon-button.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon-button.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/image-preview.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/image-preview.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/inline-input.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/inline-input.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/keybind.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/keybind.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/line-comment.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/line-comment.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/list.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/list.stories.tsx (line 15, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/logo.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/logo.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/markdown.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/markdown.stories.tsx (line 12, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/message-nav.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-nav.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/message-part.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-part.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/popover.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/popover.stories.tsx (line 16, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/progress-circle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress-circle.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/progress.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress.stories.tsx (line 15, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/provider-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/provider-icon.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/radio-group.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/radio-group.stories.tsx (line 13, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/resize-handle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/resize-handle.stories.tsx (line 17, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/select.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/select.stories.tsx (line 16, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/session-review.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-review.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/session-turn.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/spinner.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/spinner.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/sticky-accordion-header.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/sticky-accordion-header.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/switch.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/switch.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/tabs.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tabs.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/tag.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tag.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/text-field.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-field.stories.tsx (line 14, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/text-shimmer.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-shimmer.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/toast.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/toast.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/tooltip.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tooltip.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
- ./../ui/src/components/typewriter.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/typewriter.stories.tsx (line 6, col 0)
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
at _StoryIndexGenerator.getIndexAndStats (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:6085:15)
at async _StoryIndexGenerator.getIndex (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:6074:13)
at async getOptimizeDeps (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1862:15)
at async createViteServer (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1888:19)
at async Module.start (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1923:17)
at async storybookDevServer (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:7241:83)
at async buildOrThrow (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:4504:12)
at async buildDevStandalone (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:7611:66)
at async withTelemetry (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/_node-chunks/chunk-S3MWHNYJ.js:218:12)
at async dev (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/bin/core.js:2734:3)
[14:25:49.118] [WARN] Broken build, fix the error above.
You may need to refresh the browser.

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