Kaynağa Gözat

effectify Config service (#19139)

Kit Langton 3 hafta önce
ebeveyn
işleme
28f5176ffd

+ 4 - 0
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.

+ 26 - 1
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`

+ 1 - 2
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 })

+ 1 - 1
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")

+ 1 - 1
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

+ 421 - 347
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

+ 2 - 2
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>({

+ 5 - 4
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

+ 2 - 2
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()
   }
 }