Compare commits

...

14 Commits

Author SHA1 Message Date
Kit Langton
8c0fae36a6 Merge branch 'dev' into kit/effectify-config 2026-03-25 19:49:36 -04:00
Kit Langton
9fe4e2a7d4 prevent Effect.cached from caching loadGlobal failures 2026-03-25 17:05:49 -04:00
Kit Langton
d9cefe21f8 use Effect.cached for global config, remove resetGlobal
- Replace module-level _cachedGlobal with Effect.cached inside
  the layer — concurrent getGlobal() callers share the same fiber
  instead of racing
- Remove resetGlobal() facade — tests use invalidate() instead
- Document Effect.cached pattern in AGENTS.md and effect-migration.md
- Mark Config as done in migration checklist
2026-03-25 15:35:52 -04:00
Kit Langton
ddb95646ec cleanup: remove dead code, fix types, simplify updateGlobal
- readConfigFile: use typed Effect.catchIf instead of (e as any) cast
- Remove dead readFile re-export (no callers)
- Remove inaccurate comments
- updateGlobal: replace unnecessary Effect.suspend with plain if/else
2026-03-25 15:23:45 -04:00
Kit Langton
5a49a4c108 fix migration regressions in Config effectification
- Schema write-back: restore silent failure (was .catch(() => {}),
  accidentally changed to Effect.orDie which would crash on
  read-only config files)
- Legacy TOML migration: move write+unlink back inside .then()
  so they only run on successful import (old behavior)
- Account config: route through loadConfig instead of bypassing
  it, preserving TUI key normalization
2026-03-25 15:18:59 -04:00
Kit Langton
d4648e726b effectify Config service with AppFileSystem
Move all config operations (loadFile, loadConfig, loadState,
loadGlobal, update, updateGlobal, invalidate) inside the Effect
layer as Effect.fnUntraced closures over AppFileSystem.Service.
Facade functions are thin runPromise wrappers matching the
Account/Worktree pattern.
2026-03-25 15:15:01 -04:00
Kit Langton
d844e3aa25 rename instance context type 2026-03-25 15:14:42 -04:00
Kit Langton
c1df8a52c8 remove unused instance context service 2026-03-25 15:14:42 -04:00
Kit Langton
caae1d9902 inline global config load 2026-03-25 15:14:42 -04:00
Kit Langton
6ce3347e44 rename config global cache local 2026-03-25 15:14:41 -04:00
Kit Langton
b794601142 rename instance context shape 2026-03-25 15:14:41 -04:00
Kit Langton
29529b4891 simplify Config global cache handling 2026-03-25 15:14:41 -04:00
Kit Langton
30b51996d4 simplify Config effect runtime 2026-03-25 15:14:41 -04:00
Kit Langton
d284d8795d effectify Config service 2026-03-25 15:14:41 -04:00
11 changed files with 463 additions and 383 deletions

View File

@@ -47,6 +47,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.

View File

@@ -109,6 +109,31 @@ const cache =
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.
@@ -167,7 +192,7 @@ Still open and likely worth migrating:
- [x] `Worktree`
- [ ] `Bus`
- [x] `Command`
- [ ] `Config`
- [x] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`

View File

@@ -137,8 +137,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 })

View File

@@ -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")

View File

@@ -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

View File

@@ -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 { makeRunPromise } 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 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 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 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
})()
export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
global.reset()
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,
)
})
void Instance.disposeAll()
.catch(() => undefined)
.finally(() => {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
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 = makeRunPromise(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

View File

@@ -1,14 +0,0 @@
import { ServiceMap } from "effect"
import type { Project } from "@/project/project"
export declare namespace InstanceContext {
export interface Shape {
readonly directory: string
readonly worktree: string
readonly project: Project.Info
}
}
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
"opencode/InstanceContext",
) {}

View File

@@ -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>({

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -1,5 +1,4 @@
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
import { InstanceContext } from "../../src/effect/instance-context"
import { Instance } from "../../src/project/instance"
/** ConfigProvider that enables the experimental file watcher. */
@@ -15,26 +14,18 @@ export const watcherConfigLayer = ConfigProvider.layer(
* the ManagedRuntime. Cleanup is automatic — the runtime is disposed
* and Instance context is torn down when `body` completes.
*
* Layers may depend on InstanceContext (provided automatically).
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
*/
export function withServices<S>(
directory: string,
layer: Layer.Layer<S, any, InstanceContext>,
layer: Layer.Layer<S>,
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
options?: { provide?: Layer.Layer<never>[] },
) {
return Instance.provide({
directory,
fn: async () => {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({
directory: Instance.directory,
worktree: Instance.worktree,
project: Instance.project,
}),
)
let resolved: Layer.Layer<S> = layer.pipe(Layer.provide(ctx)) as any
let resolved: Layer.Layer<S> = layer
if (options?.provide) {
for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any