summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/AGENTS.md4
-rw-r--r--packages/opencode/specs/effect-migration.md27
-rw-r--r--packages/opencode/src/cli/cmd/tui/worker.ts3
-rw-r--r--packages/opencode/src/cli/network.ts2
-rw-r--r--packages/opencode/src/cli/upgrade.ts2
-rw-r--r--packages/opencode/src/config/config.ts768
-rw-r--r--packages/opencode/src/effect/instance-state.ts4
-rw-r--r--packages/opencode/src/project/instance.ts9
-rw-r--r--packages/opencode/test/config/config.test.ts4
9 files changed, 463 insertions, 360 deletions
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index 3e4c309ce..a1291f647 100644
--- a/packages/opencode/AGENTS.md
+++ b/packages/opencode/AGENTS.md
@@ -49,6 +49,10 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
+## Effect.cached for deduplication
+
+Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
+
## Instance.bind — ALS for native callbacks
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
index bac01195c..a73f8ea39 100644
--- a/packages/opencode/specs/effect-migration.md
+++ b/packages/opencode/specs/effect-migration.md
@@ -121,6 +121,31 @@ yield *
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
+## Effect.cached for deduplication
+
+Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
+
+```ts
+// Inside the layer — yield* to initialize the memo
+let cached = yield* Effect.cached(loadExpensive())
+
+const get = Effect.fn("Foo.get")(function* () {
+ return yield* cached // concurrent callers share the same fiber
+})
+
+// To invalidate: swap in a fresh memo
+const invalidate = Effect.fn("Foo.invalidate")(function* () {
+ cached = yield* Effect.cached(loadExpensive())
+})
+```
+
+Prefer `Effect.cached` over these patterns:
+- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`)
+- Storing a `Promise<void>` task for deduplication (e.g. `skill/index.ts` `ensure`)
+- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves)
+
+`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded.
+
## Scheduled Tasks
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
@@ -179,7 +204,7 @@ Still open and likely worth migrating:
- [x] `Worktree`
- [x] `Bus`
- [x] `Command`
-- [ ] `Config`
+- [x] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 76f76fa58..a83645d89 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -149,8 +149,7 @@ export const rpc = {
})
},
async reload() {
- Config.global.reset()
- await Instance.disposeAll()
+ await Config.invalidate(true)
},
async setWorkspace(input: { workspaceID?: string }) {
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts
index dd09e1689..84268e267 100644
--- a/packages/opencode/src/cli/network.ts
+++ b/packages/opencode/src/cli/network.ts
@@ -37,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
}
export async function resolveNetworkOptions(args: NetworkOptions) {
- const config = await Config.global()
+ const config = await Config.getGlobal()
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts
index e40750a2e..7b7199d4e 100644
--- a/packages/opencode/src/cli/upgrade.ts
+++ b/packages/opencode/src/cli/upgrade.ts
@@ -4,7 +4,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
- const config = await Config.global()
+ const config = await Config.getGlobal()
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 0ede11844..c398d4219 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -1,14 +1,13 @@
import { Log } from "../util/log"
import path from "path"
-import { pathToFileURL, fileURLToPath } from "url"
+import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
-import fs from "fs/promises"
-import { lazy } from "../util/lazy"
+import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
@@ -20,7 +19,7 @@ import {
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
-import { Instance } from "../project/instance"
+import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
@@ -38,6 +37,10 @@ import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Lock } from "@/util/lock"
+import { AppFileSystem } from "@/filesystem"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
+import { Effect, Layer, ServiceMap } from "effect"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -75,201 +78,6 @@ export namespace Config {
return merged
}
- export const state = Instance.state(async () => {
- const auth = await Auth.all()
-
- // Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
- // 1) Remote .well-known/opencode (org defaults)
- // 2) Global config (~/.config/opencode/opencode.json{,c})
- // 3) Custom config (OPENCODE_CONFIG)
- // 4) Project config (opencode.json{,c})
- // 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
- // 6) Inline config (OPENCODE_CONFIG_CONTENT)
- // Managed config directory is enterprise-only and always overrides everything above.
- let result: Info = {}
- for (const [key, value] of Object.entries(auth)) {
- if (value.type === "wellknown") {
- const url = key.replace(/\/+$/, "")
- process.env[value.key] = value.token
- log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
- const response = await fetch(`${url}/.well-known/opencode`)
- if (!response.ok) {
- throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
- }
- const wellknown = (await response.json()) as any
- const remoteConfig = wellknown.config ?? {}
- // Add $schema to prevent load() from trying to write back to a non-existent file
- if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- result = mergeConfigConcatArrays(
- result,
- await load(JSON.stringify(remoteConfig), {
- dir: path.dirname(`${url}/.well-known/opencode`),
- source: `${url}/.well-known/opencode`,
- }),
- )
- log.debug("loaded remote config from well-known", { url })
- }
- }
-
- // Global user config overrides remote config.
- result = mergeConfigConcatArrays(result, await global())
-
- // Custom config path overrides global config.
- if (Flag.OPENCODE_CONFIG) {
- result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
- log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
- }
-
- // Project config overrides global and remote config.
- if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
- result = mergeConfigConcatArrays(result, await loadFile(file))
- }
- }
-
- result.agent = result.agent || {}
- result.mode = result.mode || {}
- result.plugin = result.plugin || []
-
- const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
-
- // .opencode directory config overrides (project and global) config sources.
- if (Flag.OPENCODE_CONFIG_DIR) {
- log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
- }
-
- const deps = []
-
- for (const dir of unique(directories)) {
- if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- log.debug(`loading config from ${path.join(dir, file)}`)
- result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
- // to satisfy the type checker
- result.agent ??= {}
- result.mode ??= {}
- result.plugin ??= []
- }
- }
-
- deps.push(
- iife(async () => {
- const shouldInstall = await needsInstall(dir)
- if (shouldInstall) await installDependencies(dir)
- }),
- )
-
- result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
- result.agent = mergeDeep(result.agent, await loadAgent(dir))
- result.agent = mergeDeep(result.agent, await loadMode(dir))
- result.plugin.push(...(await loadPlugin(dir)))
- }
-
- // Inline config content overrides all non-managed config sources.
- if (process.env.OPENCODE_CONFIG_CONTENT) {
- result = mergeConfigConcatArrays(
- result,
- await load(process.env.OPENCODE_CONFIG_CONTENT, {
- dir: Instance.directory,
- source: "OPENCODE_CONFIG_CONTENT",
- }),
- )
- log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
- }
-
- const active = await Account.active()
- if (active?.active_org_id) {
- try {
- const [config, token] = await Promise.all([
- Account.config(active.id, active.active_org_id),
- Account.token(active.id),
- ])
- if (token) {
- process.env["OPENCODE_CONSOLE_TOKEN"] = token
- Env.set("OPENCODE_CONSOLE_TOKEN", token)
- }
-
- if (config) {
- result = mergeConfigConcatArrays(
- result,
- await load(JSON.stringify(config), {
- dir: path.dirname(`${active.url}/api/config`),
- source: `${active.url}/api/config`,
- }),
- )
- }
- } catch (err: any) {
- log.debug("failed to fetch remote account config", { error: err?.message ?? err })
- }
- }
-
- // Load managed config files last (highest priority) - enterprise admin-controlled
- // Kept separate from directories array to avoid write operations when installing plugins
- // which would fail on system directories requiring elevated permissions
- // This way it only loads config file and not skills/plugins/commands
- if (existsSync(managedDir)) {
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
- }
- }
-
- // Migrate deprecated mode field to agent field
- for (const [name, mode] of Object.entries(result.mode ?? {})) {
- result.agent = mergeDeep(result.agent ?? {}, {
- [name]: {
- ...mode,
- mode: "primary" as const,
- },
- })
- }
-
- if (Flag.OPENCODE_PERMISSION) {
- result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
- }
-
- // Backwards compatibility: legacy top-level `tools` config
- if (result.tools) {
- const perms: Record<string, Config.PermissionAction> = {}
- for (const [tool, enabled] of Object.entries(result.tools)) {
- const action: Config.PermissionAction = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
- perms.edit = action
- continue
- }
- perms[tool] = action
- }
- result.permission = mergeDeep(perms, result.permission ?? {})
- }
-
- if (!result.username) result.username = os.userInfo().username
-
- // Handle migration from autoshare to share field
- if (result.autoshare === true && !result.share) {
- result.share = "auto"
- }
-
- // Apply flag overrides for compaction settings
- if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
- result.compaction = { ...result.compaction, auto: false }
- }
- if (Flag.OPENCODE_DISABLE_PRUNE) {
- result.compaction = { ...result.compaction, prune: false }
- }
-
- result.plugin = deduplicatePlugins(result.plugin ?? [])
-
- return {
- config: result,
- directories,
- deps,
- }
- })
-
- export async function waitForDependencies() {
- const deps = await state().then((x) => x.deps)
- await Promise.all(deps)
- }
-
export async function installDependencies(dir: string) {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -325,7 +133,7 @@ export namespace Config {
async function isWritable(dir: string) {
try {
- await fs.access(dir, constants.W_OK)
+ await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
@@ -1234,123 +1042,23 @@ export namespace Config {
export type Info = z.output<typeof Info>
- export const global = lazy(async () => {
- let result: Info = pipe(
- {},
- mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
- mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
- mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
- )
-
- const legacy = path.join(Global.Path.config, "config")
- if (existsSync(legacy)) {
- await import(pathToFileURL(legacy).href, {
- with: {
- type: "toml",
- },
- })
- .then(async (mod) => {
- const { provider, model, ...rest } = mod.default
- if (provider && model) result.model = `${provider}/${model}`
- result["$schema"] = "https://opencode.ai/config.json"
- result = mergeDeep(result, rest)
- await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
- await fs.unlink(legacy)
- })
- .catch(() => {})
- }
-
- return result
- })
-
- export const { readFile } = ConfigPaths
-
- async function loadFile(filepath: string): Promise<Info> {
- log.info("loading", { path: filepath })
- const text = await readFile(filepath)
- if (!text) return {}
- return load(text, { path: filepath })
+ type State = {
+ config: Info
+ directories: string[]
+ deps: Promise<void>[]
}
- async function load(text: string, options: { path: string } | { dir: string; source: string }) {
- const original = text
- const source = "path" in options ? options.path : options.source
- const isFile = "path" in options
- const data = await ConfigPaths.parseText(
- text,
- "path" in options ? options.path : { source: options.source, dir: options.dir },
- )
-
- const normalized = (() => {
- if (!data || typeof data !== "object" || Array.isArray(data)) return data
- const copy = { ...(data as Record<string, unknown>) }
- const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
- if (!hadLegacy) return copy
- delete copy.theme
- delete copy.keybinds
- delete copy.tui
- log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
- return copy
- })()
-
- const parsed = Info.safeParse(normalized)
- if (parsed.success) {
- if (!parsed.data.$schema && isFile) {
- parsed.data.$schema = "https://opencode.ai/config.json"
- const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
- await Filesystem.write(options.path, updated).catch(() => {})
- }
- const data = parsed.data
- if (data.plugin && isFile) {
- for (let i = 0; i < data.plugin.length; i++) {
- const plugin = data.plugin[i]
- try {
- data.plugin[i] = import.meta.resolve!(plugin, options.path)
- } catch (e) {
- try {
- // import.meta.resolve sometimes fails with newly created node_modules
- const require = createRequire(options.path)
- const resolvedPath = require.resolve(plugin)
- data.plugin[i] = pathToFileURL(resolvedPath).href
- } catch {
- // Ignore, plugin might be a generic string identifier like "mcp-server"
- }
- }
- }
- }
- return data
- }
-
- throw new InvalidError({
- path: source,
- issues: parsed.error.issues,
- })
+ export interface Interface {
+ readonly get: () => Effect.Effect<Info>
+ readonly getGlobal: () => Effect.Effect<Info>
+ readonly update: (config: Info) => Effect.Effect<void>
+ readonly updateGlobal: (config: Info) => Effect.Effect<Info>
+ readonly invalidate: (wait?: boolean) => Effect.Effect<void>
+ readonly directories: () => Effect.Effect<string[]>
+ readonly waitForDependencies: () => Effect.Effect<void>
}
- export const { JsonError, InvalidError } = ConfigPaths
- export const ConfigDirectoryTypoError = NamedError.create(
- "ConfigDirectoryTypoError",
- z.object({
- path: z.string(),
- dir: z.string(),
- suggestion: z.string(),
- }),
- )
-
- export async function get() {
- return state().then((x) => x.config)
- }
-
- export async function getGlobal() {
- return global()
- }
-
- export async function update(config: Info) {
- const filepath = path.join(Instance.directory, "config.json")
- const existing = await loadFile(filepath)
- await Filesystem.writeJson(filepath, mergeDeep(existing, config))
- await Instance.dispose()
- }
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
@@ -1417,47 +1125,413 @@ export namespace Config {
})
}
- export async function updateGlobal(config: Info) {
- const filepath = globalConfigFile()
- const before = await Filesystem.readText(filepath).catch((err: any) => {
- if (err.code === "ENOENT") return "{}"
- throw new JsonError({ path: filepath }, { cause: err })
- })
+ export const { JsonError, InvalidError } = ConfigPaths
- const next = await (async () => {
- if (!filepath.endsWith(".jsonc")) {
- const existing = parseConfig(before, filepath)
- const merged = mergeDeep(existing, config)
- await Filesystem.writeJson(filepath, merged)
- return merged
- }
+ export const ConfigDirectoryTypoError = NamedError.create(
+ "ConfigDirectoryTypoError",
+ z.object({
+ path: z.string(),
+ dir: z.string(),
+ suggestion: z.string(),
+ }),
+ )
- const updated = patchJsonc(before, config)
- const merged = parseConfig(updated, filepath)
- await Filesystem.write(filepath, updated)
- return merged
- })()
-
- global.reset()
-
- void Instance.disposeAll()
- .catch(() => undefined)
- .finally(() => {
- GlobalBus.emit("event", {
- directory: "global",
- payload: {
- type: Event.Disposed.type,
- properties: {},
- },
+ export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+
+ const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
+ return yield* fs.readFileString(filepath).pipe(
+ Effect.catchIf(
+ (e) => e.reason._tag === "NotFound",
+ () => Effect.succeed(undefined),
+ ),
+ Effect.orDie,
+ )
+ })
+
+ const loadConfig = Effect.fnUntraced(function* (
+ text: string,
+ options: { path: string } | { dir: string; source: string },
+ ) {
+ const original = text
+ const source = "path" in options ? options.path : options.source
+ const isFile = "path" in options
+ const data = yield* Effect.promise(() =>
+ ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
+ )
+
+ const normalized = (() => {
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data
+ const copy = { ...(data as Record<string, unknown>) }
+ const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+ if (!hadLegacy) return copy
+ delete copy.theme
+ delete copy.keybinds
+ delete copy.tui
+ log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
+ return copy
+ })()
+
+ const parsed = Info.safeParse(normalized)
+ if (parsed.success) {
+ if (!parsed.data.$schema && isFile) {
+ parsed.data.$schema = "https://opencode.ai/config.json"
+ const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
+ yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
+ }
+ const data = parsed.data
+ if (data.plugin && isFile) {
+ for (let i = 0; i < data.plugin.length; i++) {
+ const plugin = data.plugin[i]
+ try {
+ data.plugin[i] = import.meta.resolve!(plugin, options.path)
+ } catch (e) {
+ try {
+ const require = createRequire(options.path)
+ const resolvedPath = require.resolve(plugin)
+ data.plugin[i] = pathToFileURL(resolvedPath).href
+ } catch {
+ // Ignore, plugin might be a generic string identifier like "mcp-server"
+ }
+ }
+ }
+ }
+ return data
+ }
+
+ throw new InvalidError({
+ path: source,
+ issues: parsed.error.issues,
})
})
- return next
+ const loadFile = Effect.fnUntraced(function* (filepath: string) {
+ log.info("loading", { path: filepath })
+ const text = yield* readConfigFile(filepath)
+ if (!text) return {} as Info
+ return yield* loadConfig(text, { path: filepath })
+ })
+
+ const loadGlobal = Effect.fnUntraced(function* () {
+ let result: Info = pipe(
+ {},
+ mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
+ mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
+ mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
+ )
+
+ const legacy = path.join(Global.Path.config, "config")
+ if (existsSync(legacy)) {
+ yield* Effect.promise(() =>
+ import(pathToFileURL(legacy).href, { with: { type: "toml" } })
+ .then(async (mod) => {
+ const { provider, model, ...rest } = mod.default
+ if (provider && model) result.model = `${provider}/${model}`
+ result["$schema"] = "https://opencode.ai/config.json"
+ result = mergeDeep(result, rest)
+ await fsNode.writeFile(
+ path.join(Global.Path.config, "config.json"),
+ JSON.stringify(result, null, 2),
+ )
+ await fsNode.unlink(legacy)
+ })
+ .catch(() => {}),
+ )
+ }
+
+ return result
+ })
+
+ let cachedGlobal = yield* Effect.cached(
+ loadGlobal().pipe(Effect.orElseSucceed(() => ({}) as Info)),
+ )
+
+ const getGlobal = Effect.fn("Config.getGlobal")(function* () {
+ return yield* cachedGlobal
+ })
+
+ const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
+ const auth = yield* Effect.promise(() => Auth.all())
+
+ let result: Info = {}
+ for (const [key, value] of Object.entries(auth)) {
+ if (value.type === "wellknown") {
+ const url = key.replace(/\/+$/, "")
+ process.env[value.key] = value.token
+ log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
+ const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
+ if (!response.ok) {
+ throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
+ }
+ const wellknown = (yield* Effect.promise(() => response.json())) as any
+ const remoteConfig = wellknown.config ?? {}
+ if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
+ result = mergeConfigConcatArrays(
+ result,
+ yield* loadConfig(JSON.stringify(remoteConfig), {
+ dir: path.dirname(`${url}/.well-known/opencode`),
+ source: `${url}/.well-known/opencode`,
+ }),
+ )
+ log.debug("loaded remote config from well-known", { url })
+ }
+ }
+
+ result = mergeConfigConcatArrays(result, yield* getGlobal())
+
+ if (Flag.OPENCODE_CONFIG) {
+ result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
+ log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
+ }
+
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ for (const file of yield* Effect.promise(() =>
+ ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
+ )) {
+ result = mergeConfigConcatArrays(result, yield* loadFile(file))
+ }
+ }
+
+ result.agent = result.agent || {}
+ result.mode = result.mode || {}
+ result.plugin = result.plugin || []
+
+ const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
+
+ if (Flag.OPENCODE_CONFIG_DIR) {
+ log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+ }
+
+ const deps: Promise<void>[] = []
+
+ for (const dir of unique(directories)) {
+ if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
+ for (const file of ["opencode.jsonc", "opencode.json"]) {
+ log.debug(`loading config from ${path.join(dir, file)}`)
+ result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
+ result.agent ??= {}
+ result.mode ??= {}
+ result.plugin ??= []
+ }
+ }
+
+ deps.push(
+ iife(async () => {
+ const shouldInstall = await needsInstall(dir)
+ if (shouldInstall) await installDependencies(dir)
+ }),
+ )
+
+ result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
+ result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
+ result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
+ result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
+ }
+
+ if (process.env.OPENCODE_CONFIG_CONTENT) {
+ result = mergeConfigConcatArrays(
+ result,
+ yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+ dir: ctx.directory,
+ source: "OPENCODE_CONFIG_CONTENT",
+ }),
+ )
+ log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+ }
+
+ const active = yield* Effect.promise(() => Account.active())
+ if (active?.active_org_id) {
+ yield* Effect.gen(function* () {
+ const [config, token] = yield* Effect.promise(() =>
+ Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]),
+ )
+ if (token) {
+ process.env["OPENCODE_CONSOLE_TOKEN"] = token
+ Env.set("OPENCODE_CONSOLE_TOKEN", token)
+ }
+
+ if (config) {
+ result = mergeConfigConcatArrays(
+ result,
+ yield* loadConfig(JSON.stringify(config), {
+ dir: path.dirname(`${active.url}/api/config`),
+ source: `${active.url}/api/config`,
+ }),
+ )
+ }
+ }).pipe(
+ Effect.catchDefect((err) => {
+ log.debug("failed to fetch remote account config", {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ return Effect.void
+ }),
+ )
+ }
+
+ if (existsSync(managedDir)) {
+ for (const file of ["opencode.jsonc", "opencode.json"]) {
+ result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
+ }
+ }
+
+ for (const [name, mode] of Object.entries(result.mode ?? {})) {
+ result.agent = mergeDeep(result.agent ?? {}, {
+ [name]: {
+ ...mode,
+ mode: "primary" as const,
+ },
+ })
+ }
+
+ if (Flag.OPENCODE_PERMISSION) {
+ result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
+ }
+
+ if (result.tools) {
+ const perms: Record<string, Config.PermissionAction> = {}
+ for (const [tool, enabled] of Object.entries(result.tools)) {
+ const action: Config.PermissionAction = enabled ? "allow" : "deny"
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ perms.edit = action
+ continue
+ }
+ perms[tool] = action
+ }
+ result.permission = mergeDeep(perms, result.permission ?? {})
+ }
+
+ if (!result.username) result.username = os.userInfo().username
+
+ if (result.autoshare === true && !result.share) {
+ result.share = "auto"
+ }
+
+ if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
+ result.compaction = { ...result.compaction, auto: false }
+ }
+ if (Flag.OPENCODE_DISABLE_PRUNE) {
+ result.compaction = { ...result.compaction, prune: false }
+ }
+
+ result.plugin = deduplicatePlugins(result.plugin ?? [])
+
+ return {
+ config: result,
+ directories,
+ deps,
+ }
+ })
+
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Config.state")(function* (ctx) {
+ return yield* loadInstanceState(ctx)
+ }),
+ )
+
+ const get = Effect.fn("Config.get")(function* () {
+ return yield* InstanceState.use(state, (s) => s.config)
+ })
+
+ const directories = Effect.fn("Config.directories")(function* () {
+ return yield* InstanceState.use(state, (s) => s.directories)
+ })
+
+ const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
+ yield* InstanceState.useEffect(state, (s) =>
+ Effect.promise(() => Promise.all(s.deps).then(() => undefined)),
+ )
+ })
+
+ const update = Effect.fn("Config.update")(function* (config: Info) {
+ const file = path.join(Instance.directory, "config.json")
+ const existing = yield* loadFile(file)
+ yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
+ yield* Effect.promise(() => Instance.dispose())
+ })
+
+ const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
+ cachedGlobal = yield* Effect.cached(
+ loadGlobal().pipe(Effect.orElseSucceed(() => ({}) as Info)),
+ )
+ const task = Instance.disposeAll()
+ .catch(() => undefined)
+ .finally(() =>
+ GlobalBus.emit("event", {
+ directory: "global",
+ payload: {
+ type: Event.Disposed.type,
+ properties: {},
+ },
+ }),
+ )
+ if (wait) yield* Effect.promise(() => task)
+ else void task
+ })
+
+ const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
+ const file = globalConfigFile()
+ const before = (yield* readConfigFile(file)) ?? "{}"
+
+ let next: Info
+ if (!file.endsWith(".jsonc")) {
+ const existing = parseConfig(before, file)
+ const merged = mergeDeep(existing, config)
+ yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
+ next = merged
+ } else {
+ const updated = patchJsonc(before, config)
+ next = parseConfig(updated, file)
+ yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
+ }
+
+ yield* invalidate()
+ return next
+ })
+
+ return Service.of({
+ get,
+ getGlobal,
+ update,
+ updateGlobal,
+ invalidate,
+ directories,
+ waitForDependencies,
+ })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+
+ const { runPromise } = makeRuntime(Service, defaultLayer)
+
+ export async function get() {
+ return runPromise((svc) => svc.get())
+ }
+
+ export async function getGlobal() {
+ return runPromise((svc) => svc.getGlobal())
+ }
+
+ export async function update(config: Info) {
+ return runPromise((svc) => svc.update(config))
+ }
+
+ export async function updateGlobal(config: Info) {
+ return runPromise((svc) => svc.updateGlobal(config))
+ }
+
+ export async function invalidate(wait = false) {
+ return runPromise((svc) => svc.invalidate(wait))
}
export async function directories() {
- return state().then((x) => x.directories)
+ return runPromise((svc) => svc.directories())
+ }
+
+ export async function waitForDependencies() {
+ return runPromise((svc) => svc.waitForDependencies())
}
}
-Filesystem.write
-Filesystem.write
diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts
index fe3339ee6..6873ec255 100644
--- a/packages/opencode/src/effect/instance-state.ts
+++ b/packages/opencode/src/effect/instance-state.ts
@@ -1,5 +1,5 @@
import { Effect, ScopedCache, Scope } from "effect"
-import { Instance, type Shape } from "@/project/instance"
+import { Instance, type InstanceContext } from "@/project/instance"
import { registerDisposer } from "./instance-registry"
const TypeId = "~opencode/InstanceState"
@@ -11,7 +11,7 @@ export interface InstanceState<A, E = never, R = never> {
export namespace InstanceState {
export const make = <A, E = never, R = never>(
- init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
+ init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 4c9b2e107..5dddfe627 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -7,13 +7,14 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
-export interface Shape {
+export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
-const context = Context.create<Shape>("instance")
-const cache = new Map<string, Promise<Shape>>()
+
+const context = Context.create<InstanceContext>("instance")
+const cache = new Map<string, Promise<InstanceContext>>()
const disposal = {
all: undefined as Promise<void> | undefined,
@@ -52,7 +53,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
})
}
-function track(directory: string, next: Promise<Shape>) {
+function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 76786c54a..dc2397b38 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -34,7 +34,7 @@ async function check(map: (dir: string) => string) {
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
- Config.global.reset()
+ await Config.invalidate()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
@@ -52,7 +52,7 @@ async function check(map: (dir: string) => string) {
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
- Config.global.reset()
+ await Config.invalidate()
}
}