import { Log } from "../util" import path from "path" import { pathToFileURL } from "url" import os from "os" import z from "zod" import { mergeDeep, pipe } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { NamedError } from "@opencode-ai/core/util/error" import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { Instance, type InstanceContext } from "../project/instance" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" import { ConfigLSP } from "./lsp" import { ConfigManaged } from "./managed" import { ConfigMCP } from "./mcp" import { ConfigModelID } from "./model-id" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPermission } from "./permission" import { ConfigPlugin } from "./plugin" import { ConfigProvider } from "./provider" import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { ConfigSandbox } from "./sandbox" import { parseQimingAgentSandboxConfig } from "@/sandbox/env" import { Npm } from "@opencode-ai/core/npm" const log = Log.create({ service: "config" }) // Custom merge function that concatenates array fields instead of replacing them function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } return merged } function normalizeLoadedConfig(data: unknown, source: string) { if (!isRecord(data)) return data const copy = { ...data } 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 } async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { // Normalize path-like plugin specs while we still know which config file declared them. // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location. config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath) } return config } export const Server = ConfigServer.Server.zod export const Layout = ConfigLayout.Layout.zod export type Layout = ConfigLayout.Layout const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ identifier: "LogLevel", description: "Log level", }) // The Effect Schema is the canonical source of truth. The `.zod` compatibility // surface is derived so existing Hono validators keep working without a parallel // Zod definition. // // The walker emits `z.object({...})` which is non-strict by default. Config // historically uses `.strict()` (additionalProperties: false in openapi.json), // so layer that on after derivation. Re-apply the Config ref afterward // since `.strict()` strips the walker's meta annotation. export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", }), shell: Schema.optional(Schema.String).annotate({ description: "Default shell to use for terminal and bash tool", }), logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }), server: Schema.optional(ConfigServer.Server).annotate({ description: "Server configuration for opencode serve and web commands", }), command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({ description: "Command configuration, see https://opencode.ai/docs/commands", }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), watcher: Schema.optional( Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), }), ), snapshot: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", }), // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))), share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({ description: "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", }), autoshare: Schema.optional(Schema.Boolean).annotate({ description: "@deprecated Use 'share' field instead. Share newly created sessions automatically", }), autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({ description: "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", }), disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Disable providers that are loaded automatically", }), enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "When set, ONLY these providers will be enabled. All other providers will be ignored", }), model: Schema.optional(ConfigModelID).annotate({ description: "Model to use in the format of provider/model, eg anthropic/claude-2", }), small_model: Schema.optional(ConfigModelID).annotate({ description: "Small model to use for tasks like title generation in the format of provider/model", }), default_agent: Schema.optional(Schema.String).annotate({ description: "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", }), username: Schema.optional(Schema.String).annotate({ description: "Custom username to display in conversations instead of system username", }), mode: Schema.optional( Schema.StructWithRest( Schema.Struct({ build: Schema.optional(ConfigAgent.Info), plan: Schema.optional(ConfigAgent.Info), }), [Schema.Record(Schema.String, ConfigAgent.Info)], ), ).annotate({ description: "@deprecated Use `agent` field instead." }), agent: Schema.optional( Schema.StructWithRest( Schema.Struct({ // primary plan: Schema.optional(ConfigAgent.Info), build: Schema.optional(ConfigAgent.Info), // subagent general: Schema.optional(ConfigAgent.Info), explore: Schema.optional(ConfigAgent.Info), // specialized title: Schema.optional(ConfigAgent.Info), summary: Schema.optional(ConfigAgent.Info), compaction: Schema.optional(ConfigAgent.Info), }), [Schema.Record(Schema.String, ConfigAgent.Info)], ), ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }), provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({ description: "Custom provider configurations and model overrides", }), mcp: Schema.optional( Schema.Record( Schema.String, Schema.Union([ ConfigMCP.Info, // Matches the legacy `{ enabled: false }` form used to disable a server. Schema.Struct({ enabled: Schema.Boolean }), ]), ), ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), formatter: Schema.optional(ConfigFormatter.Info), lsp: Schema.optional(ConfigLSP.Info), instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermission.Info), sandbox: Schema.optional(ConfigSandbox.Info).annotate({ description: "Execution sandbox for built-in write/edit/bash tools", }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), }), ), tool_output: Schema.optional( Schema.Struct({ max_lines: Schema.optional(PositiveInt).annotate({ description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", }), max_bytes: Schema.optional(PositiveInt).annotate({ description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", }), }), ).annotate({ description: "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", }), compaction: Schema.optional( Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ description: "Enable automatic compaction when context is full (default: true)", }), prune: Schema.optional(Schema.Boolean).annotate({ description: "Enable pruning of old tool outputs (default: true)", }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", }), }), ), experimental: Schema.optional( Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), openTelemetry: Schema.optional(Schema.Boolean).annotate({ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", }), primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", }), continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ description: "Continue the agent loop when a tool call is denied", }), mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), }), ), }) .annotate({ identifier: "Config" }) .pipe( withStatics((s) => ({ zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType< DeepMutable> >, })), ) // Uses the shared `DeepMutable` from `@/util/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // with the file and scope it came from so later runtime code can make location-sensitive decisions. plugin_origins?: ConfigPlugin.Origin[] } type State = { config: Info directories: string[] deps: Fiber.Fiber[] consoleState: ConsoleState } export interface Interface { readonly get: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect readonly updateGlobal: (config: Info) => Effect.Effect readonly invalidate: (wait?: boolean) => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } export class Service extends Context.Service()("@opencode/Config") {} function globalConfigFile() { const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => path.join(Global.Path.config, file), ) for (const file of candidates) { if (existsSync(file)) return file } return candidates[0] } function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { const edits = modify(input, path, patch, { formattingOptions: { insertSpaces: true, tabSize: 2, }, }) return applyEdits(input, edits) } return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input) } function writable(info: Info) { const { plugin_origins: _plugin_origins, ...next } = info return next } function writableGlobal(info: Info) { const next = writable(info) // When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key if ("shell" in next && next.shell === "") return { ...next, shell: undefined } return next } export const ConfigDirectoryTypoError = NamedError.create( "ConfigDirectoryTypoError", z.object({ path: z.string(), dir: z.string(), suggestion: z.string(), }), ) export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const authSvc = yield* Auth.Service const accountSvc = yield* Account.Service const env = yield* Env.Service const npmSvc = yield* Npm.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 source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options }, ), ) const parsed = ConfigParse.jsonc(expanded, source) const data = ConfigParse.effectSchema(Info, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) if (!data.$schema) { data.$schema = "https://opencode.ai/config.json" const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) } return data }) 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 }) const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( loadGlobal().pipe( Effect.tapError((error) => Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), ), Effect.orElseSucceed((): Info => ({})), ), Duration.infinity, ) const getGlobal = Effect.fn("Config.getGlobal")(function* () { return yield* cachedGlobal }) const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) { const gitignore = path.join(dir, ".gitignore") const hasIgnore = yield* fs.existsSafe(gitignore) if (!hasIgnore) { yield* fs .writeFileString( gitignore, ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), ) .pipe( Effect.catchIf( (e) => e.reason._tag === "PermissionDenied", () => Effect.void, ), ) } }) const loadInstanceState = Effect.fn("Config.loadInstanceState")( function* (ctx: InstanceContext) { const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} const consoleManagedProviders = new Set() let activeOrgName: string | undefined const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" return "global" }) const mergePluginOrigins = Effect.fnUntraced(function* ( source: string, // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step // is attached. list: ConfigPlugin.Spec[] | undefined, // Scope can be inferred from the source path, but some callers already know whether the config should // behave as global or local and can pass that explicitly. kind?: ConfigPlugin.Scope, ) { if (!list?.length) return const hit = kind ?? (yield* pluginScopeForSource(source)) // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics. const plugins = ConfigPlugin.deduplicatePluginOrigins([ ...(result.plugin_origins ?? []), ...list.map((spec) => ({ spec, source, scope: hit })), ]) result.plugin = plugins.map((item) => item.spec) result.plugin_origins = plugins }) const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { result = mergeConfigConcatArrays(result, next) return mergePluginOrigins(source, next.plugin, kind) } 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 { config?: Record } const remoteConfig = wellknown.config ?? {} if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = `${url}/.well-known/opencode` const next = yield* loadConfig(JSON.stringify(remoteConfig), { dir: path.dirname(source), source, }) yield* merge(source, next, "global") log.debug("loaded remote config from well-known", { url }) } } const global = yield* getGlobal() yield* merge(Global.Path.config, global, "global") if (Flag.OPENCODE_CONFIG) { yield* merge(Flag.OPENCODE_CONFIG, 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* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge(file, yield* loadFile(file), "local") } } result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] const directories = yield* 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: Fiber.Fiber[] = [] for (const dir of directories) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) yield* merge(source, yield* loadFile(source)) result.agent ??= {} result.mode ??= {} result.plugin ??= [] } } yield* ensureGitignore(dir).pipe(Effect.orDie) const dep = yield* npmSvc .install(dir, { add: [ { name: "@opencode-ai/plugin", version: InstallationLocal ? undefined : InstallationVersion, }, ], }) .pipe( Effect.exit, Effect.tap((exit) => Exit.isFailure(exit) ? Effect.sync(() => { log.warn("background dependency install failed", { dir, error: String(exit.cause) }) }) : Effect.void, ), Effect.asVoid, Effect.forkDetach, ) deps.push(dep) result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir))) // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load // returns normalized Specs and we only need to attach origin metadata here. const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) yield* mergePluginOrigins(dir, list) } // OPENCODE_MODEL env var overrides model (highest priority) if (process.env.OPENCODE_MODEL) { result.model = process.env.OPENCODE_MODEL log.debug("loaded model overrides from OPENCODE_MODEL env var", { model: result.model }) } if (process.env.OPENCODE_CONFIG_CONTENT) { const source = "OPENCODE_CONFIG_CONTENT" const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { dir: ctx.directory, source, }) yield* merge(source, next, "local") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } const activeAccount = Option.getOrUndefined( yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), ) if (activeAccount?.active_org_id) { const accountID = activeAccount.id const orgID = activeAccount.active_org_id const url = activeAccount.url yield* Effect.gen(function* () { const [configOpt, tokenOpt] = yield* Effect.all( [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], { concurrency: 2 }, ) if (Option.isSome(tokenOpt)) { process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) } if (Option.isSome(configOpt)) { const source = `${url}/api/config` const next = yield* loadConfig(JSON.stringify(configOpt.value), { dir: path.dirname(source), source, }) for (const providerID of Object.keys(next.provider ?? {})) { consoleManagedProviders.add(providerID) } yield* merge(source, next, "global") } }).pipe( Effect.withSpan("Config.loadActiveOrgConfig"), Effect.catch((err) => { log.debug("failed to fetch remote account config", { error: err instanceof Error ? err.message : String(err), }) return Effect.void }), ) } const managedDir = ConfigManaged.managedConfigDir() if (existsSync(managedDir)) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(managedDir, file) yield* merge(source, yield* loadFile(source), "global") } } // macOS managed preferences (.mobileconfig deployed via MDM) override everything const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { result = mergeConfigConcatArrays( result, yield* loadConfig(managed.text, { dir: path.dirname(managed.source), source: managed.source, }), ) } 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: ConfigPermission.Action = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch") { 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 } } const forcedSandbox = parseQimingAgentSandboxConfig() if (forcedSandbox) { result.sandbox = forcedSandbox log.info("applied QIMING_AGENT_SANDBOX_CONFIG", { sandbox_mode: result.sandbox.sandbox_mode, }) } return { config: result, directories, deps, consoleState: { consoleManagedProviders: Array.from(consoleManagedProviders), activeOrgName, switchableOrgCount: 0, }, } }, Effect.provideService(AppFileSystem.Service, fs), ) const state = yield* InstanceState.make( Effect.fn("Config.state")(function* (ctx) { return yield* loadInstanceState(ctx).pipe(Effect.orDie) }), ) 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 getConsoleState = Effect.fn("Config.getConsoleState")(function* () { return yield* InstanceState.use(state, (s) => s.consoleState) }) const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { yield* InstanceState.useEffect(state, (s) => Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), ) }) const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) { const dir = yield* InstanceState.directory const file = path.join(dir, "config.json") const existing = yield* loadFile(file) yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose()) }) const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { yield* invalidateGlobal 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)) ?? "{}" const patch = writableGlobal(config) let next: Info if (!file.endsWith(".jsonc")) { const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) const merged = mergeDeep(writable(existing), patch) yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) next = merged } else { const updated = patchJsonc(before, patch) next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } yield* invalidate() return next }) return Service.of({ get, getGlobal, getConsoleState, update, updateGlobal, invalidate, directories, waitForDependencies, }) }), ) export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), )