diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 3e4c309ce229..a1291f64771d 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 43b3194858b4..18248cc22431 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` 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 3d8c00cc5205..fab82b6e5543 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -156,8 +156,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 dd09e1689f5b..84268e267539 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -37,7 +37,7 @@ export function withNetworkOptions(yargs: Argv) { } 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 e40750a2eca6..7b7199d4ea53 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 0ede11844e40..c398d42193df 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 = {} - 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 - 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 { - 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[] } - 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) } - 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 + readonly getGlobal: () => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect + readonly invalidate: (wait?: boolean) => Effect.Effect + readonly directories: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect } - 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()("@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 = 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) } + 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[] = [] + + 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 = {} + 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( + 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 fe3339ee6892..6873ec255c96 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 { export namespace InstanceState { export const make = ( - init: (ctx: Shape) => Effect.Effect, + init: (ctx: InstanceContext) => Effect.Effect, ): Effect.Effect>, never, R | Scope.Scope> => Effect.gen(function* () { const cache = yield* ScopedCache.make({ diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 4c9b2e107bc8..5dddfe627fbc 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("instance") -const cache = new Map>() + +const context = Context.create("instance") +const cache = new Map>() const disposal = { all: undefined as Promise | undefined, @@ -52,7 +53,7 @@ function boot(input: { directory: string; init?: () => Promise; project?: P }) } -function track(directory: string, next: Promise) { +function track(directory: string, next: Promise) { 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 76786c54a3cf..dc2397b38b51 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() } }