diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 867bc0fe9ea..494f19e02bf 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -5,6 +5,7 @@ import { generateObject, type ModelMessage } from "ai" import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" +import { State } from "../project/state" import { mergeDeep } from "remeda" export namespace Agent { @@ -39,145 +40,149 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() - const defaultTools = cfg.tools ?? {} - const defaultPermission: Info["permission"] = { - edit: "allow", - bash: { - "*": "allow", - }, - webfetch: "allow", - doom_loop: "ask", - external_directory: "ask", - } - const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) - - const planPermission = mergeAgentPermissions( - { - edit: "deny", + const state = State.register( + "agent", + () => Instance.directory, + async () => { + const cfg = await Config.get() + const defaultTools = cfg.tools ?? {} + const defaultPermission: Info["permission"] = { + edit: "allow", bash: { - "cut*": "allow", - "diff*": "allow", - "du*": "allow", - "file *": "allow", - "find * -delete*": "ask", - "find * -exec*": "ask", - "find * -fprint*": "ask", - "find * -fls*": "ask", - "find * -fprintf*": "ask", - "find * -ok*": "ask", - "find *": "allow", - "git diff*": "allow", - "git log*": "allow", - "git show*": "allow", - "git status*": "allow", - "git branch": "allow", - "git branch -v": "allow", - "grep*": "allow", - "head*": "allow", - "less*": "allow", - "ls*": "allow", - "more*": "allow", - "pwd*": "allow", - "rg*": "allow", - "sort --output=*": "ask", - "sort -o *": "ask", - "sort*": "allow", - "stat*": "allow", - "tail*": "allow", - "tree -o *": "ask", - "tree*": "allow", - "uniq*": "allow", - "wc*": "allow", - "whereis*": "allow", - "which*": "allow", - "*": "ask", + "*": "allow", }, webfetch: "allow", - }, - cfg.permission ?? {}, - ) + doom_loop: "ask", + external_directory: "ask", + } + const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) - const result: Record = { - general: { - name: "general", - description: - "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", - tools: { - todoread: false, - todowrite: false, - ...defaultTools, + const planPermission = mergeAgentPermissions( + { + edit: "deny", + bash: { + "cut*": "allow", + "diff*": "allow", + "du*": "allow", + "file *": "allow", + "find * -delete*": "ask", + "find * -exec*": "ask", + "find * -fprint*": "ask", + "find * -fls*": "ask", + "find * -fprintf*": "ask", + "find * -ok*": "ask", + "find *": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git status*": "allow", + "git branch": "allow", + "git branch -v": "allow", + "grep*": "allow", + "head*": "allow", + "less*": "allow", + "ls*": "allow", + "more*": "allow", + "pwd*": "allow", + "rg*": "allow", + "sort --output=*": "ask", + "sort -o *": "ask", + "sort*": "allow", + "stat*": "allow", + "tail*": "allow", + "tree -o *": "ask", + "tree*": "allow", + "uniq*": "allow", + "wc*": "allow", + "whereis*": "allow", + "which*": "allow", + "*": "ask", + }, + webfetch: "allow", }, - options: {}, - permission: agentPermission, - mode: "subagent", - builtIn: true, - }, - build: { - name: "build", - tools: { ...defaultTools }, - options: {}, - permission: agentPermission, - mode: "primary", - builtIn: true, - }, - plan: { - name: "plan", - options: {}, - permission: planPermission, - tools: { - ...defaultTools, + cfg.permission ?? {}, + ) + + const result: Record = { + general: { + name: "general", + description: + "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", + tools: { + todoread: false, + todowrite: false, + ...defaultTools, + }, + options: {}, + permission: agentPermission, + mode: "subagent", + builtIn: true, }, - mode: "primary", - builtIn: true, - }, - } - for (const [key, value] of Object.entries(cfg.agent ?? {})) { - if (value.disable) { - delete result[key] - continue - } - let item = result[key] - if (!item) - item = result[key] = { - name: key, - mode: "all", + build: { + name: "build", + tools: { ...defaultTools }, + options: {}, permission: agentPermission, + mode: "primary", + builtIn: true, + }, + plan: { + name: "plan", options: {}, - tools: {}, - builtIn: false, - } - const { name, model, prompt, tools, description, temperature, top_p, mode, permission, color, ...extra } = value - item.options = { - ...item.options, - ...extra, + permission: planPermission, + tools: { + ...defaultTools, + }, + mode: "primary", + builtIn: true, + }, } - if (model) item.model = Provider.parseModel(model) - if (prompt) item.prompt = prompt - if (tools) + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete result[key] + continue + } + let item = result[key] + if (!item) + item = result[key] = { + name: key, + mode: "all", + permission: agentPermission, + options: {}, + tools: {}, + builtIn: false, + } + const { name, model, prompt, tools, description, temperature, top_p, mode, color, permission, ...extra } = value + item.options = { + ...item.options, + ...extra, + } + if (model) item.model = Provider.parseModel(model) + if (prompt) item.prompt = prompt + if (tools) + item.tools = { + ...item.tools, + ...tools, + } item.tools = { + ...defaultTools, ...item.tools, - ...tools, } - item.tools = { - ...defaultTools, - ...item.tools, - } - if (description) item.description = description - if (temperature != undefined) item.temperature = temperature - if (top_p != undefined) item.topP = top_p - if (mode) item.mode = mode - if (color) item.color = color - // just here for consistency & to prevent it from being added as an option - if (name) item.name = name + if (description) item.description = description + if (temperature != undefined) item.temperature = temperature + if (top_p != undefined) item.topP = top_p + if (mode) item.mode = mode + if (color) item.color = color + // just here for consistency & to prevent it from being added as an option + if (name) item.name = name - if (permission ?? cfg.permission) { - item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) + if (permission ?? cfg.permission) { + item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) + } } - } - return result - }) + return result + }, + ) export async function get(agent: string) { return state().then((x) => x[agent]) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 5e1ad9dc405..7be404d955c 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,6 +1,7 @@ import z from "zod" import { Config } from "../config/config" import { Instance } from "../project/instance" +import { State } from "../project/state" import PROMPT_INITIALIZE from "./template/initialize.txt" import { Bus } from "../bus" import { Identifier } from "../id/id" @@ -36,32 +37,36 @@ export namespace Command { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() + const state = State.register( + "command", + () => Instance.directory, + async () => { + const cfg = await Config.get() - const result: Record = {} + const result: Record = {} - for (const [name, command] of Object.entries(cfg.command ?? {})) { - result[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, - template: command.template, - subtask: command.subtask, + for (const [name, command] of Object.entries(cfg.command ?? {})) { + result[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + template: command.template, + subtask: command.subtask, + } } - } - if (result[Default.INIT] === undefined) { - result[Default.INIT] = { - name: Default.INIT, - description: "create/update AGENTS.md", - template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), + if (result[Default.INIT] === undefined) { + result[Default.INIT] = { + name: Default.INIT, + description: "create/update AGENTS.md", + template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), + } } - } - return result - }) + return result + }, + ) export async function get(name: string) { return state().then((x) => x[name]) diff --git a/packages/opencode/src/config/backup.ts b/packages/opencode/src/config/backup.ts new file mode 100644 index 00000000000..9df6ba2c7dc --- /dev/null +++ b/packages/opencode/src/config/backup.ts @@ -0,0 +1,17 @@ +import fs from "fs/promises" + +export async function createBackup(filepath: string): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backupPath = `${filepath}.bak-${timestamp}` + + if (await Bun.file(filepath).exists()) { + await fs.copyFile(filepath, backupPath) + } + + return backupPath +} + +export async function restoreBackup(backupPath: string, targetPath: string): Promise { + await fs.copyFile(backupPath, targetPath) + await fs.unlink(backupPath) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3ca8c25de2f..60350167afb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -7,37 +7,102 @@ import { ModelsDev } from "../provider/models" import { mergeDeep, pipe } from "remeda" import { Global } from "../global" import fs from "fs/promises" -import { lazy } from "../util/lazy" +import { resolveGlobalFile } from "./global-file" import { NamedError } from "../util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" import { Instance } from "../project/instance" +import { State } from "../project/state" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" +import { Bus } from "../bus" +import type { ConfigDiff } from "./diff" +import { pathToFileURL } from "url" export namespace Config { const log = Log.create({ service: "config" }) + const WINDOWS_RELATIVE_PREFIXES = [".\\", "..\\", "~\\"] + + const isPathLikePluginSpecifier = (value: unknown): value is string => { + if (typeof value !== "string") return false + if (value.startsWith("file://")) return true + if (value.startsWith("./") || value.startsWith("../")) return true + if (value.startsWith("~/")) return true + if (WINDOWS_RELATIVE_PREFIXES.some((prefix) => value.startsWith(prefix))) { + return true + } + if (value.startsWith("/") || path.isAbsolute(value)) { + return true + } + return false + } - export const state = Instance.state(async () => { - const auth = await Auth.all() - let result = await global() + const resolvePluginFileReference = (plugin: string, configFilepath: string): string => { + if (plugin.startsWith("file://")) { + return plugin + } - // Override with custom config if provided - if (Flag.OPENCODE_CONFIG) { - result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + const normalizeWindowsPath = (input: string) => input.replace(/\\/g, "/") + + if (plugin.startsWith("~/")) { + const homePath = path.join(os.homedir(), plugin.slice(2)) + return pathToFileURL(homePath).href } + if (WINDOWS_RELATIVE_PREFIXES.some((prefix) => plugin.startsWith(prefix))) { + const withoutPrefix = plugin.startsWith("~\\") + ? path.join(os.homedir(), plugin.slice(2)) + : path.resolve(path.dirname(configFilepath), plugin) + return pathToFileURL(withoutPrefix).href + } + + if (path.isAbsolute(plugin)) { + return pathToFileURL(plugin).href + } + + try { + const base = pathToFileURL(configFilepath).href + const resolved = new URL(plugin, base).href + return normalizeWindowsPath(resolved) + } catch { + return plugin + } + } + + export const Event = { + Updated: Bus.event( + "config.updated", + z.object({ + scope: z.enum(["project", "global"]), + directory: z.string().optional(), + refreshed: z.boolean().optional(), + before: z.any(), + after: z.any(), + diff: z.any(), + }), + ), + } + + async function loadStateFromDisk() { + const directory = Instance.directory + const worktree = Instance.worktree + const auth = await Auth.all() + let result = await global() for (const file of ["opencode.jsonc", "opencode.json"]) { - const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) + const found = await Filesystem.findUp(file, directory, worktree) for (const resolved of found.toReversed()) { result = mergeDeep(result, await loadFile(resolved)) } } + if (Flag.OPENCODE_CONFIG) { + result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG)) + log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + } + if (Flag.OPENCODE_CONFIG_CONTENT) { result = mergeDeep(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") @@ -60,8 +125,8 @@ export namespace Config { ...(await Array.fromAsync( Filesystem.up({ targets: [".opencode"], - start: Instance.directory, - stop: Instance.worktree, + start: directory, + stop: worktree, }), )), ] @@ -72,14 +137,13 @@ export namespace Config { } const promises: Promise[] = [] + const pluginFiles: string[] = [] for (const dir of directories) { await assertValid(dir) - if (dir.endsWith(".opencode")) { + if (dir !== Global.Path.config) { for (const file of ["opencode.jsonc", "opencode.json"]) { - log.debug(`loading config from ${path.join(dir, file)}`) result = mergeDeep(result, await loadFile(path.join(dir, file))) - // to satisy the type checker result.agent ??= {} result.mode ??= {} result.plugin ??= [] @@ -90,11 +154,15 @@ export namespace Config { 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))) + pluginFiles.push(...(await loadPlugin(dir))) } await Promise.allSettled(promises) - // Migrate deprecated mode field to agent field + if (!result.plugin) { + result.plugin = [] + } + result.plugin.push(...pluginFiles) + for (const [name, mode] of Object.entries(result.mode)) { result.agent = mergeDeep(result.agent ?? {}, { [name]: { @@ -110,12 +178,10 @@ export namespace Config { if (!result.username) result.username = os.userInfo().username - // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" } - // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" } @@ -126,7 +192,14 @@ export namespace Config { config: result, directories, } - }) + } + + export const state = State.register("config", () => Instance.directory, loadStateFromDisk) + + export async function readFreshConfig() { + const state = await loadStateFromDisk() + return state.config + } const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools"].join(",")}}/`) async function assertValid(dir: string) { @@ -632,12 +705,14 @@ export namespace Config { export type Info = z.output - export const global = lazy(async () => { + async function loadGlobalConfig(): Promise { + const globalFile = await resolveGlobalFile() + 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"))), + mergeDeep(await loadFile(globalFile)), ) await import(path.join(Global.Path.config, "config"), { @@ -656,7 +731,11 @@ export namespace Config { .catch(() => {}) return result - }) + } + + export async function global() { + return loadGlobalConfig() + } async function loadFile(filepath: string): Promise { log.info("loading", { path: filepath }) @@ -743,12 +822,12 @@ export namespace Config { await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)) } const data = parsed.data - if (data.plugin) { + if (data.plugin?.length) { for (let i = 0; i < data.plugin.length; i++) { const plugin = data.plugin[i] - try { - data.plugin[i] = import.meta.resolve!(plugin, configFilepath) - } catch (err) {} + if (isPathLikePluginSpecifier(plugin)) { + data.plugin[i] = resolvePluginFileReference(plugin, configFilepath) + } } } return data @@ -789,11 +868,22 @@ export namespace Config { return state().then((x) => x.config) } - export async function update(config: Info) { - const filepath = path.join(Instance.directory, "config.json") - const existing = await loadFile(filepath) - await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) - await Instance.dispose() + export async function update(input: { scope?: "project" | "global"; update: Info; directory?: string }): Promise<{ + before: Info + after: Info + diff: ConfigDiff + diffForPublish: ConfigDiff + filepath: string + }> { + const scope = input.scope ?? "project" + const directory = input.directory ?? Instance.directory + + const { update: persistUpdate } = await import("./persist") + return persistUpdate({ + scope, + update: input.update, + directory, + }) } export async function directories() { diff --git a/packages/opencode/src/config/diff.ts b/packages/opencode/src/config/diff.ts new file mode 100644 index 00000000000..9b2edb714f1 --- /dev/null +++ b/packages/opencode/src/config/diff.ts @@ -0,0 +1,123 @@ +import { isDeepEqual } from "remeda" +import type { Config } from "./config" + +export interface ConfigDiff { + provider?: boolean + providerKeys?: { added: string[]; removed: string[]; modified: string[] } + mcp?: boolean + mcpKeys?: { added: string[]; removed: string[]; modified: string[] } + lsp?: boolean + formatter?: boolean + watcher?: boolean + plugin?: boolean + pluginAdded?: string[] + pluginRemoved?: string[] + agent?: boolean + command?: boolean + permission?: boolean + tools?: boolean + instructions?: boolean + share?: boolean + autoshare?: boolean + theme?: boolean + model?: boolean + small_model?: boolean + disabled_providers?: boolean +} + +function computeKeysChanged( + before: Record | undefined, + after: Record | undefined, +): { added: string[]; removed: string[]; modified: string[] } { + const beforeKeys = Object.keys(before ?? {}) + const afterKeys = Object.keys(after ?? {}) + + const added = afterKeys.filter((k) => !beforeKeys.includes(k)) + const removed = beforeKeys.filter((k) => !afterKeys.includes(k)) + const modified = afterKeys.filter((k) => { + if (!beforeKeys.includes(k)) return false + return !isDeepEqual(before?.[k], after?.[k]) + }) + + return { added, removed, modified } +} + +export function computeDiff(before: Config.Info, after: Config.Info): ConfigDiff { + const diff: ConfigDiff = {} + + if (!isDeepEqual(before.provider, after.provider)) { + diff.provider = true + diff.providerKeys = computeKeysChanged(before.provider, after.provider) + } + + if (!isDeepEqual(before.mcp, after.mcp)) { + diff.mcp = true + diff.mcpKeys = computeKeysChanged(before.mcp, after.mcp) + } + + if (!isDeepEqual(before.lsp, after.lsp)) { + diff.lsp = true + } + + if (!isDeepEqual(before.formatter, after.formatter)) { + diff.formatter = true + } + + if (!isDeepEqual(before.watcher, after.watcher)) { + diff.watcher = true + } + + if (!isDeepEqual(before.plugin, after.plugin)) { + diff.plugin = true + const beforePlugins = before.plugin ?? [] + const afterPlugins = after.plugin ?? [] + diff.pluginAdded = afterPlugins.filter((p) => !beforePlugins.includes(p)) + diff.pluginRemoved = beforePlugins.filter((p) => !afterPlugins.includes(p)) + } + + if (!isDeepEqual(before.agent, after.agent)) { + diff.agent = true + } + + if (!isDeepEqual(before.command, after.command)) { + diff.command = true + } + + if (!isDeepEqual(before.permission, after.permission)) { + diff.permission = true + } + + if (!isDeepEqual(before.tools, after.tools)) { + diff.tools = true + } + + if (!isDeepEqual(before.instructions, after.instructions)) { + diff.instructions = true + } + + if (before.share !== after.share) { + diff.share = true + } + + if (before.autoshare !== after.autoshare) { + diff.autoshare = true + } + + if (before.theme !== after.theme) { + diff.theme = true + } + + if (before.model !== after.model) { + diff.model = true + } + + if (before.small_model !== after.small_model) { + diff.small_model = true + } + + if (!isDeepEqual(before.disabled_providers, after.disabled_providers)) { + diff.disabled_providers = true + } + + return diff +} diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts new file mode 100644 index 00000000000..b0f1bfda450 --- /dev/null +++ b/packages/opencode/src/config/error.ts @@ -0,0 +1,45 @@ +import z from "zod" +import { NamedError } from "@/util/error" + +export const ConfigUpdateError = NamedError.create( + "ConfigUpdateError", + z.object({ + filepath: z.string(), + scope: z.enum(["project", "global"]), + directory: z.string(), + cause: z.any().optional(), + }), +) + +export const ConfigValidationError = NamedError.create( + "ConfigValidationError", + z.object({ + filepath: z.string(), + errors: z.array( + z.object({ + field: z.string(), + message: z.string(), + expected: z.string().optional(), + received: z.string().optional(), + }), + ), + }), +) + +export const ConfigWriteConflictError = NamedError.create( + "ConfigWriteConflictError", + z.object({ + filepath: z.string(), + timeout: z.number(), + waitedMs: z.number(), + }), +) + +export const ConfigWriteError = NamedError.create( + "ConfigWriteError", + z.object({ + filepath: z.string(), + operation: z.enum(["create", "write", "backup", "restore"]), + cause: z.any(), + }), +) diff --git a/packages/opencode/src/config/global-file.ts b/packages/opencode/src/config/global-file.ts new file mode 100644 index 00000000000..40d722f81d4 --- /dev/null +++ b/packages/opencode/src/config/global-file.ts @@ -0,0 +1,8 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "../global" + +export async function resolveGlobalFile(): Promise { + await fs.mkdir(Global.Path.config, { recursive: true }) + return path.join(Global.Path.config, "opencode.jsonc") +} diff --git a/packages/opencode/src/config/hot-reload.ts b/packages/opencode/src/config/hot-reload.ts new file mode 100644 index 00000000000..3f769b2441e --- /dev/null +++ b/packages/opencode/src/config/hot-reload.ts @@ -0,0 +1,3 @@ +export function isConfigHotReloadEnabled(): boolean { + return process.env.OPENCODE_CONFIG_HOT_RELOAD === "true" +} diff --git a/packages/opencode/src/config/invalidation.ts b/packages/opencode/src/config/invalidation.ts new file mode 100644 index 00000000000..09c684d0424 --- /dev/null +++ b/packages/opencode/src/config/invalidation.ts @@ -0,0 +1,188 @@ +import { Bus } from "@/bus" +import { Config } from "./config" +import { Instance } from "@/project/instance" +import { Log } from "@/util/log" +import type { ConfigDiff } from "./diff" +import { Context } from "../util/context" +import { isConfigHotReloadEnabled } from "./hot-reload" + +const log = Log.create({ service: "config.invalidation" }) + +type ApplyInput = { + scope: "project" | "global" + directory?: string + diff: ConfigDiff + refreshed?: boolean +} + +let setupPromise: Promise | undefined +async function invalidateProvider(diff: ConfigDiff): Promise { + await Instance.invalidate("provider") +} + +async function invalidateMCP(diff: ConfigDiff): Promise { + await Instance.invalidate("mcp") +} + +async function invalidateLSP(diff: ConfigDiff): Promise { + await Instance.invalidate("lsp") +} + +async function invalidateFileWatcher(): Promise { + await Instance.invalidate("filewatcher") +} + +async function invalidatePlugin(diff: ConfigDiff): Promise { + await Instance.invalidate("plugin") +} + +async function invalidateToolRegistry(): Promise { + await Instance.invalidate("tool-registry") +} + +async function invalidatePermission(): Promise { + await Instance.invalidate("permission") +} + +async function invalidateCommandAgentFormat(diff: ConfigDiff): Promise { + if (diff.command) await Instance.invalidate("command") + if (diff.agent) await Instance.invalidate("agent") + if (diff.formatter) await Instance.invalidate("format") +} + +async function invalidateUIAndPrompts(diff: ConfigDiff): Promise { + if (diff.instructions) await Instance.invalidate("instructions") + if (diff.theme) await Instance.invalidate("theme") +} + +async function applyInternal(input: ApplyInput) { + const { diff, scope } = input + const targetDirectory = input.directory ?? process.cwd() + const directoryForLog = input.directory ?? targetDirectory + const alreadyRefreshed = input.refreshed === true + + await Instance.provide({ + directory: targetDirectory, + fn: async () => { + if (!alreadyRefreshed) { + await Instance.invalidate("config") + } + log.info("config.invalidate.stateRefreshed", { scope, directory: directoryForLog }) + + if (Object.keys(diff).length === 0) { + log.info("config.update.noop", { scope, directory: directoryForLog }) + return + } + + const sections = Object.keys(diff).filter((k) => diff[k as keyof ConfigDiff] === true) + const targets = new Set() + const tasks: Promise[] = [] + const providerChanged = diff.provider || diff.model || diff.small_model || diff.disabled_providers + if (providerChanged) { + targets.add("provider") + tasks.push(invalidateProvider(diff)) + } + + const mcpChanged = diff.mcp + if (mcpChanged) { + targets.add("mcp") + tasks.push(invalidateMCP(diff)) + } + + const lspChanged = diff.lsp || diff.formatter + if (lspChanged) { + targets.add("lsp") + tasks.push(invalidateLSP(diff)) + } + + const watcherChanged = diff.watcher + if (watcherChanged) { + targets.add("filewatcher") + tasks.push(invalidateFileWatcher()) + } + + const pluginChanged = diff.plugin + if (pluginChanged) { + targets.add("plugin") + tasks.push(invalidatePlugin(diff)) + targets.add("tool-registry") + tasks.push(invalidateToolRegistry()) + } + + const permissionChanged = diff.permission + if (permissionChanged) { + targets.add("permission") + tasks.push(invalidatePermission()) + } + + const commandAgentFormatChanged = diff.command || diff.agent || diff.formatter + if (commandAgentFormatChanged) { + if (diff.command) targets.add("command") + if (diff.agent) targets.add("agent") + if (diff.formatter) targets.add("format") + tasks.push(invalidateCommandAgentFormat(diff)) + } + + const shareSettingsChanged = diff.share || diff.autoshare + const uiChanged = diff.theme || diff.instructions || shareSettingsChanged + if (uiChanged) { + if (diff.theme) targets.add("theme") + if (diff.instructions) targets.add("instructions") + if (shareSettingsChanged) targets.add("share-settings") + tasks.push(invalidateUIAndPrompts(diff)) + } + + log.info("config.invalidate.start", { + scope, + directory: directoryForLog, + sections, + targets: Array.from(targets), + }) + + try { + await Promise.all(tasks) + } catch (error) { + log.error("Targeted config invalidation failed", { + error: String(error), + }) + } + + log.info("config.invalidate.complete", { + scope, + directory: directoryForLog, + sections, + targets: Array.from(targets), + }) + }, + }) +} +export namespace ConfigInvalidation { + export async function apply(input: ApplyInput) { + try { + await applyInternal(input) + } catch (error) { + if (error instanceof Context.NotFound) { + log.warn("config.invalidate.missingContext", { error: String(error) }) + return + } + throw error + } + } + + export async function setup() { + if (setupPromise) { + return setupPromise + } + + setupPromise = (async () => { + if (isConfigHotReloadEnabled()) { + Bus.subscribe(Config.Event.Updated, async (event) => { + const { diff, scope, directory, refreshed } = event.properties as any + await apply({ diff, scope, directory, refreshed }) + }) + } + })() + + return setupPromise + } +} diff --git a/packages/opencode/src/config/lock.ts b/packages/opencode/src/config/lock.ts new file mode 100644 index 00000000000..007207d6c4a --- /dev/null +++ b/packages/opencode/src/config/lock.ts @@ -0,0 +1,133 @@ +import path from "path" +import fs from "fs/promises" +import { constants } from "fs" +import { Log } from "@/util/log" + +const log = Log.create({ service: "config.lock" }) +const fileLocks = new Map>() +const LOCKFILE_SUFFIX = ".lock" +const LOCKFILE_STALE_AFTER_MS = 60000 +const LOCKFILE_RETRY_DELAY_MS = 25 + +interface LockOptions { + timeout?: number + staleAfter?: number +} + +function buildLockfilePath(target: string) { + return `${target}${LOCKFILE_SUFFIX}` +} + +async function removeLockfile(lockfile: string): Promise { + await fs.unlink(lockfile).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "ENOENT") return + log.warn("failed to remove lockfile", { filepath: lockfile, error: String(error) }) + }) +} + +async function acquireFilesystemLock(params: { + filepath: string + timeout: number + staleAfter: number + startTime: number +}): Promise<() => Promise> { + const lockfile = buildLockfilePath(params.filepath) + await fs.mkdir(path.dirname(lockfile), { recursive: true }) + let warned = false + + while (true) { + const handle = await fs + .open(lockfile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + .catch((error: NodeJS.ErrnoException) => { + if (error?.code === "EEXIST") return null + throw error + }) + + if (handle) { + const payload = JSON.stringify({ + pid: process.pid, + createdAt: new Date().toISOString(), + }) + await handle.write(payload) + await handle.close() + return async () => removeLockfile(lockfile) + } + + const waited = Date.now() - params.startTime + + if (!warned && waited > 5000) { + warned = true + log.warn("waiting for filesystem lock", { + filepath: params.filepath, + waited, + }) + } + + if (waited > params.timeout) { + throw new Error(`Lock timeout: could not acquire filesystem lock for ${params.filepath} after ${waited}ms`) + } + + const stat = await fs.stat(lockfile).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "ENOENT") return + throw error + }) + + if (stat) { + const age = Date.now() - stat.mtimeMs + if (age > params.staleAfter) { + log.warn("removing stale lockfile", { + filepath: params.filepath, + age, + }) + await removeLockfile(lockfile) + } + } + + await Bun.sleep(LOCKFILE_RETRY_DELAY_MS) + } +} + +export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => Promise> { + const normalized = path.normalize(filepath) + const timeout = options?.timeout ?? 30000 + const staleAfter = options?.staleAfter ?? LOCKFILE_STALE_AFTER_MS + const startTime = Date.now() + + while (fileLocks.has(normalized)) { + const waited = Date.now() - startTime + + if (waited > 5000 && waited < 5100) { + log.warn("lock acquisition taking longer than expected", { + filepath: normalized, + waited, + }) + } + + if (waited > timeout) { + throw new Error(`Lock timeout: could not acquire lock for ${normalized} after ${waited}ms`) + } + + await fileLocks.get(normalized) + await Bun.sleep(10) + } + + let releaseFn: () => void + const lockPromise = new Promise((resolve) => { + releaseFn = resolve + }) + + fileLocks.set(normalized, lockPromise) + + const releaseFilesystem = await acquireFilesystemLock({ + filepath: normalized, + timeout, + staleAfter, + startTime, + }) + + return async () => { + fileLocks.delete(normalized) + releaseFn!() + await releaseFilesystem() + } +} diff --git a/packages/opencode/src/config/persist.ts b/packages/opencode/src/config/persist.ts new file mode 100644 index 00000000000..066b88b67f3 --- /dev/null +++ b/packages/opencode/src/config/persist.ts @@ -0,0 +1,183 @@ +import path from "path" +import fs from "fs/promises" +import { mergeDeep } from "remeda" +import { Config } from "./config" +import { acquireLock } from "./lock" +import { createBackup, restoreBackup } from "./backup" +import { writeConfigFile, writeFileAtomically } from "./write" +import { computeDiff, type ConfigDiff } from "./diff" +import { ConfigUpdateError, ConfigValidationError, ConfigWriteError } from "./error" +import { Instance } from "@/project/instance" +import { State } from "@/project/state" +import { resolveGlobalFile } from "./global-file" +import { Log } from "@/util/log" +import { parse as parseJsonc } from "jsonc-parser" +import z from "zod" +import { isConfigHotReloadEnabled } from "./hot-reload" + +const log = Log.create({ service: "config.persist" }) + +async function determineTargetFile(scope: "project" | "global", directory: string): Promise { + if (scope === "global") { + return resolveGlobalFile() + } + + const candidates = [ + path.join(directory, ".opencode", "opencode.jsonc"), + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, "opencode.jsonc"), + path.join(directory, "opencode.json"), + ] + + for (const candidate of candidates) { + if (await Bun.file(candidate).exists()) { + return candidate + } + } + + const defaultPath = path.join(directory, ".opencode", "opencode.jsonc") + await fs.mkdir(path.dirname(defaultPath), { recursive: true }) + return defaultPath +} + +async function loadFileContent(filepath: string): Promise { + if (!(await Bun.file(filepath).exists())) { + return null + } + + return Bun.file(filepath).text() +} + +function normalizeConfig(config: Config.Info): Config.Info { + return { + $schema: config.$schema || "https://opencode.ai/schema/config.json", + ...config, + agent: config.agent || {}, + mode: config.mode || {}, + plugin: config.plugin || [], + } +} + +export async function update(input: { scope: "project" | "global"; update: Config.Info; directory: string }): Promise<{ + before: Config.Info + after: Config.Info + diff: ConfigDiff + diffForPublish: ConfigDiff + filepath: string +}> { + const filepath = await determineTargetFile(input.scope, input.directory) + const release = await acquireLock(filepath) + + log.info("config.update.start", { + scope: input.scope, + directory: input.directory, + filepath, + }) + + const beforeGlobal = input.scope === "global" ? await Config.global() : undefined + + try { + const backupPath = await createBackup(filepath) + + try { + const before = await Config.get() + + const existingContent = await loadFileContent(filepath) + const fileContent = existingContent ? parseJsonc(existingContent) : {} + const previousParsed = existingContent ? Config.Info.safeParse(fileContent) : undefined + const previousNormalized = previousParsed?.success ? normalizeConfig(previousParsed.data) : undefined + + const merged = mergeDeep(fileContent, input.update) + + const validated = Config.Info.parse(merged) + + const normalized = normalizeConfig(validated) + const writerDiff = previousNormalized ? computeDiff(previousNormalized, normalized) : undefined + + await writeConfigFile(filepath, normalized, existingContent, { + diff: writerDiff, + previous: previousNormalized, + }).catch((error) => { + log.error("JSONC write failed, attempting fallback", { + filepath, + error: String(error), + }) + + const content = JSON.stringify(normalized, null, 2) + "\n" + return writeFileAtomically(filepath, content) + }) + + const hotReloadEnabled = isConfigHotReloadEnabled() + if (hotReloadEnabled && input.scope === "global") { + await State.invalidate("config") + } + if (hotReloadEnabled && input.scope === "project") { + await Instance.invalidate("config") + } + + log.info("config.update.cacheInvalidated", { + scope: input.scope, + directory: input.directory, + filepath, + cacheInvalidated: hotReloadEnabled && input.scope === "global", + hotReloadEnabled, + }) + + const after = hotReloadEnabled ? await Config.get() : await Config.readFreshConfig() + const afterGlobal = input.scope === "global" ? await Config.global() : undefined + + const diff = computeDiff(before, after) + const diffForPublish = input.scope === "global" ? computeDiff(beforeGlobal!, afterGlobal!) : diff + + if (await Bun.file(backupPath).exists()) { + await fs.unlink(backupPath) + } + + log.info("config.update.persisted", { + scope: input.scope, + directory: input.directory, + filepath, + }) + + return { before, after, diff, diffForPublish, filepath } + } catch (error) { + if (await Bun.file(backupPath).exists()) { + await restoreBackup(backupPath, filepath).catch((restoreError) => { + log.error("Failed to restore backup", { + backupPath, + filepath, + error: String(restoreError), + }) + throw new ConfigWriteError({ + filepath, + operation: "restore", + cause: restoreError, + }) + }) + } + + if (error instanceof z.ZodError) { + const errors = error.issues.map((e: z.ZodIssue) => ({ + field: e.path.join("."), + message: e.message, + expected: "expected" in e ? String((e as any).expected) : undefined, + received: JSON.stringify("received" in e ? (e as any).received : undefined), + })) + + throw new ConfigValidationError({ filepath, errors }) + } + + throw new ConfigUpdateError( + { + filepath, + scope: input.scope, + directory: input.directory, + cause: error, + }, + { cause: error instanceof Error ? error : undefined }, + ) + } + } finally { + await release() + } +} diff --git a/packages/opencode/src/config/write.ts b/packages/opencode/src/config/write.ts new file mode 100644 index 00000000000..3fb6e5e2f21 --- /dev/null +++ b/packages/opencode/src/config/write.ts @@ -0,0 +1,295 @@ +import path from "path" +import fs from "fs/promises" +import { constants } from "fs" +import { randomUUID } from "crypto" +import { + modify, + applyEdits, + type ModificationOptions, + parse as parseJsonc, + type ParseError, + printParseErrorCode, +} from "jsonc-parser" +import { Log } from "@/util/log" +import type { Config } from "./config" +import type { ConfigDiff } from "./diff" +import { isDeepEqual } from "remeda" + +const log = Log.create({ service: "config.write" }) + +interface WriteConfigOptions { + diff?: ConfigDiff + previous?: Config.Info +} + +export async function writeConfigFile( + filepath: string, + newConfig: Config.Info, + existingContent: string | null, + options?: WriteConfigOptions, +): Promise { + const file = Bun.file(filepath) + const isJsonc = filepath.endsWith(".jsonc") || filepath.endsWith(".json") + + if (!existingContent || !(await file.exists())) { + const content = JSON.stringify(newConfig, null, 2) + "\n" + await writeFileAtomically(filepath, content) + return + } + + if (isJsonc) { + const updated = applyIncrementalUpdates(existingContent, newConfig, options) + validateJsonc(updated) + await writeFileAtomically(filepath, updated) + return + } + + const content = JSON.stringify(newConfig, null, 2) + "\n" + await writeFileAtomically(filepath, content) +} + +type UpdateInstruction = { path: (string | number)[]; value: unknown } +type UnknownRecord = Record +const nestedRecordKeys = new Set([ + "provider", + "mcp", + "agent", + "command", + "permission", + "formatter", + "lsp", + "tools", + "mode", +]) +const diffKeyToConfigKey: Record = { + provider: ["provider"], + mcp: ["mcp"], + lsp: ["lsp"], + formatter: ["formatter"], + watcher: ["watcher"], + plugin: ["plugin"], + agent: ["agent"], + command: ["command"], + permission: ["permission"], + tools: ["tools"], + instructions: ["instructions"], + share: ["share"], + autoshare: ["autoshare"], + theme: ["theme"], + model: ["model"], + small_model: ["small_model"], + disabled_providers: ["disabled_providers"], +} + +function applyIncrementalUpdates(content: string, newConfig: Config.Info, options?: WriteConfigOptions) { + const formattingOptions: ModificationOptions = { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + eol: "\n", + }, + } + + let currentContent = content + + if (!options?.previous) { + for (const [key, value] of Object.entries(newConfig)) { + const edits = modify(currentContent, [key], value, formattingOptions) + currentContent = applyEdits(currentContent, edits) + } + return currentContent + } + + const instructions = buildUpdateInstructions(newConfig, options.previous, options.diff) + + if (instructions.length === 0) { + return currentContent + } + + for (const instruction of instructions) { + const edits = modify(currentContent, instruction.path, instruction.value, formattingOptions) + currentContent = applyEdits(currentContent, edits) + } + + return currentContent +} + +function buildUpdateInstructions( + newConfig: Config.Info, + previous: Config.Info, + diff?: ConfigDiff, +): UpdateInstruction[] { + const updateKeys = new Set() + if (diff) { + for (const [diffKey, configKeys] of Object.entries(diffKeyToConfigKey)) { + const flag = diff[diffKey as keyof ConfigDiff] + if (!flag) continue + for (const configKey of configKeys) { + updateKeys.add(configKey) + } + } + } + + const allKeys = new Set([...Object.keys(previous ?? {}), ...Object.keys(newConfig)]) + for (const key of allKeys) { + if (updateKeys.has(key)) continue + const prevValue = (previous as UnknownRecord)[key] + const nextValue = (newConfig as UnknownRecord)[key] + if (!isDeepEqual(prevValue, nextValue)) { + updateKeys.add(key) + } + } + + const instructions: UpdateInstruction[] = [] + for (const key of updateKeys) { + const nextHasKey = hasOwn(newConfig, key) + const prevValue = (previous as UnknownRecord)[key] + const nextValue = nextHasKey ? (newConfig as UnknownRecord)[key] : undefined + + if (!nextHasKey) { + instructions.push({ path: [key], value: undefined }) + continue + } + + if (nextValue === undefined) { + instructions.push({ path: [key], value: undefined }) + continue + } + + if (shouldUseNestedUpdates(key, prevValue, nextValue)) { + const nestedInstructions = buildNestedInstructions( + key, + prevValue as UnknownRecord | undefined, + nextValue as UnknownRecord | undefined, + diff, + ) + instructions.push(...nestedInstructions) + continue + } + + instructions.push({ path: [key], value: nextValue }) + } + + return sortInstructions(instructions) +} + +function buildNestedInstructions( + key: string, + previousValue: Record | undefined, + nextValue: Record | undefined, + diff?: ConfigDiff, +): UpdateInstruction[] { + const instructions: UpdateInstruction[] = [] + if (!previousValue && !nextValue) { + return instructions + } + + const diffChildKeys = new Set() + if (key === "provider" && diff?.providerKeys) { + for (const bucket of Object.values(diff.providerKeys)) { + bucket.forEach((child) => diffChildKeys.add(child)) + } + } + if (key === "mcp" && diff?.mcpKeys) { + for (const bucket of Object.values(diff.mcpKeys)) { + bucket.forEach((child) => diffChildKeys.add(child)) + } + } + + const previousKeys = Object.keys(previousValue ?? {}) + const nextKeys = Object.keys(nextValue ?? {}) + for (const name of [...previousKeys, ...nextKeys]) { + diffChildKeys.add(name) + } + + for (const childKey of diffChildKeys) { + const nextHasKey = hasOwn(nextValue, childKey) + const prevChild = previousValue ? (previousValue as UnknownRecord)[childKey] : undefined + if (!nextHasKey) { + if (typeof prevChild !== "undefined") { + instructions.push({ path: [key, childKey], value: undefined }) + } + continue + } + const nextChild = (nextValue as UnknownRecord)[childKey] + if (!isDeepEqual(prevChild, nextChild)) { + instructions.push({ path: [key, childKey], value: nextChild }) + } + } + + return instructions +} + +function shouldUseNestedUpdates(key: string, previousValue: unknown, nextValue: unknown) { + if (!nestedRecordKeys.has(key)) return false + if (typeof previousValue !== "object" || previousValue === null) return false + if (typeof nextValue !== "object" || nextValue === null) return false + return true +} + +function hasOwn(value: unknown, key: string): boolean { + if (!value || typeof value !== "object") return false + return Object.prototype.hasOwnProperty.call(value, key) +} + +function sortInstructions(instructions: UpdateInstruction[]): UpdateInstruction[] { + return instructions.sort((a, b) => { + if (a.path.length !== b.path.length) { + return a.path.length - b.path.length + } + const aPath = a.path.join(".") + const bPath = b.path.join(".") + if (aPath === bPath) return 0 + return aPath < bPath ? -1 : 1 + }) +} + +function validateJsonc(content: string) { + const errors: ParseError[] = [] + parseJsonc(content, errors, { allowTrailingComma: true }) + + if (errors.length === 0) { + return + } + + const details = errors + .map((error) => { + const code = printParseErrorCode(error.error) + return `${code} at ${error.offset}` + }) + .join("; ") + + throw new SyntaxError(`Invalid JSONC produced while persisting config: ${details}`) +} + +export async function writeFileAtomically(filepath: string, content: string): Promise { + const directory = path.dirname(filepath) + const tempName = `${path.basename(filepath)}.${randomUUID()}.tmp` + const tempPath = path.join(directory, tempName) + await fs.mkdir(directory, { recursive: true }) + const handle = await fs.open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600) + await handle.writeFile(content, "utf8") + await handle.sync() + await handle.close() + await fs.rename(tempPath, filepath).catch(async (error) => { + await fs.unlink(tempPath).catch(() => {}) + throw error + }) + await syncDirectory(directory) +} + +async function syncDirectory(directory: string): Promise { + if (process.platform === "win32") return + const handle = await fs.open(directory, constants.O_RDONLY).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "EISDIR") return + if (error?.code === "ENOENT") return + log.warn("directory sync skipped", { directory, error: String(error) }) + return + }) + if (!handle) return + + await handle.sync().catch((error: NodeJS.ErrnoException) => { + log.warn("directory sync failed", { directory, error: String(error) }) + }) + await handle.close() +} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index aae7061c17a..b4b8607758c 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -123,8 +123,7 @@ export namespace File { type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } let fetching = false - const fn = async (result: Entry) => { - fetching = true + const fetchEntries = async (result: Entry) => { const set = new Set() for await (const file of Ripgrep.files({ cwd: Instance.directory })) { result.files.push(file) @@ -140,14 +139,24 @@ export namespace File { } } cache = result - fetching = false } - fn(cache) + const refresh = (result: Entry) => { + fetching = true + fetchEntries(result) + .catch((error) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return + log.error("failed to refresh files", { error }) + }) + .finally(() => { + fetching = false + }) + } + refresh(cache) return { async files() { if (!fetching) { - fn({ + refresh({ files: [], dirs: [], }) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index d5985b58266..7c60aa446c3 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -2,6 +2,7 @@ import z from "zod" import { Bus } from "../bus" import { Flag } from "../flag/flag" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" @@ -29,7 +30,9 @@ export namespace FileWatcher { return createWrapper(binding) as typeof import("@parcel/watcher") }) - const state = Instance.state( + const state = State.register( + "filewatcher", + () => Instance.directory, async () => { if (Instance.project.vcs !== "git") return {} log.info("init") diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 9cb4545b0dc..1c0f1cd4f04 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -8,6 +8,7 @@ import * as Formatter from "./formatter" import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" +import { State } from "../project/state" export namespace Format { const log = Log.create({ service: "format" }) @@ -23,37 +24,39 @@ export namespace Format { }) export type Status = z.infer - const state = Instance.state(async () => { - const enabled: Record = {} - const cfg = await Config.get() + const state = State.register( + "format", + () => Instance.directory, + async () => { + const enabled: Record = {} + const cfg = await Config.get() - const formatters: Record = {} - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (item.disabled) { - delete formatters[name] - continue + const formatters: Record = {} + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + if (item.disabled) { + delete formatters[name] + continue + } + const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { + command: [], + extensions: [], + ...item, + }) + if (result.command.length === 0) continue + result.enabled = async () => true + result.name = name + formatters[name] = result } - const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) - - if (result.command.length === 0) continue - - result.enabled = async () => true - result.name = name - formatters[name] = result - } - return { - enabled, - formatters, - } - }) + return { + enabled, + formatters, + } + }, + ) async function isEnabled(item: Formatter.Info) { const s = await state() diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 6b53797422c..1d651fc80b0 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { Config } from "../config/config" import { spawn } from "child_process" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Bus } from "../bus" export namespace LSP { @@ -58,7 +59,9 @@ export namespace LSP { }) export type DocumentSymbol = z.infer - const state = Instance.state( + const state = State.register( + "lsp", + () => Instance.directory, async () => { const clients: LSPClient.Info[] = [] const servers: Record = {} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 149cd76f632..bc2c1d7c8e9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -7,6 +7,7 @@ import { Log } from "../util/log" import { NamedError } from "../util/error" import z from "zod/v4" import { Instance } from "../project/instance" +import { State } from "../project/state" import { withTimeout } from "@/util/timeout" export namespace MCP { @@ -52,7 +53,9 @@ export namespace MCP { export type Status = z.infer type MCPClient = Awaited> - const state = Instance.state( + const state = State.register( + "mcp", + () => Instance.directory, async () => { const cfg = await Config.get() const config = cfg.mcp ?? {} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 3a4a9901b71..b079e2972db 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -4,6 +4,7 @@ import { Log } from "../util/log" import { Identifier } from "../id/id" import { Plugin } from "../plugin" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Wildcard } from "../util/wildcard" export namespace Permission { @@ -49,7 +50,9 @@ export namespace Permission { ), } - const state = Instance.state( + const state = State.register( + "permission", + () => Instance.directory, () => { const pending: { [sessionID: string]: { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index ff07e68a7ff..40395a52856 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -6,51 +6,65 @@ import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Flag } from "../flag/flag" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const state = Instance.state(async () => { - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - // @ts-ignore - fetch type incompatibility - fetch: async (...args) => Server.App().fetch(...args), - }) - const config = await Config.get() - const hooks = [] - const input: PluginInput = { - client, - project: Instance.project, - worktree: Instance.worktree, - directory: Instance.directory, - $: Bun.$, - } - const plugins = [...(config.plugin ?? [])] - if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { - plugins.push("opencode-copilot-auth@0.0.5") - plugins.push("opencode-anthropic-auth@0.0.2") - } - for (let plugin of plugins) { - log.info("loading plugin", { path: plugin }) - if (!plugin.startsWith("file://")) { - const lastAtIndex = plugin.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin - const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" - plugin = await BunProc.install(pkg, version) + const state = State.register( + "plugin", + () => Instance.directory, + async () => { + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + // @ts-ignore - fetch type incompatibility + fetch: async (...args) => Server.App().fetch(...args), + }) + const config = await Config.get() + const hooks = [] + const input: PluginInput = { + client, + project: Instance.project, + worktree: Instance.worktree, + directory: Instance.directory, + $: Bun.$, } - const mod = await import(plugin) - for (const [_name, fn] of Object.entries(mod)) { - const init = await fn(input) - hooks.push(init) + const plugins = [...(config.plugin ?? [])] + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + plugins.push("opencode-copilot-auth@0.0.5") + plugins.push("opencode-anthropic-auth@0.0.2") + } + for (let plugin of plugins) { + log.info("loading plugin", { path: plugin }) + if (!plugin.startsWith("file://")) { + const lastAtIndex = plugin.lastIndexOf("@") + const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin + const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" + plugin = await BunProc.install(pkg, version) + } + const mod = await import(plugin) + for (const [_name, fn] of Object.entries(mod)) { + const init = await fn(input) + hooks.push(init) + } } - } - return { - hooks, - input, - } - }) + return { + hooks, + input, + } + }, + async (state) => { + for (const hook of state.hooks) { + if ("cleanup" in hook && typeof hook.cleanup === "function") { + await (hook.cleanup as () => Promise)().catch((error: Error) => { + log.error("Plugin cleanup failed", { error }) + }) + } + } + }, + ) export async function trigger< Name extends Exclude, "auth" | "event" | "tool">, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 4d5d6fa90d3..e5702767f52 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,9 +10,11 @@ import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" import { Log } from "@/util/log" +import { ConfigInvalidation } from "../config/invalidation" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) + await ConfigInvalidation.setup() await Plugin.init() Share.init() Format.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 6e8ebb7a0e3..4acecefe0e2 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -48,6 +48,31 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + async invalidate(name: string) { + await State.invalidate(name, Instance.directory) + }, + async forEach(fn: (directory: string) => Promise): Promise> { + const errors: Array<{ directory: string; error: Error }> = [] + + for (const [directory, contextPromise] of cache) { + const ctx = await contextPromise + await context + .provide(ctx, async () => { + await fn(directory) + }) + .catch((error) => { + errors.push({ + directory, + error: error instanceof Error ? error : new Error(String(error)), + }) + }) + } + + if (errors.length > 0) { + Log.Default.warn("some instances failed during forEach", { errors }) + } + return errors + }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 5846bf85686..08d98b5ed33 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -6,8 +6,15 @@ export namespace State { dispose?: (state: any) => Promise } + interface NamedEntry { + key: string + init: any + dispose?: (state: any) => Promise + } + const log = Log.create({ service: "state" }) const recordsByKey = new Map>() + const namedRegistry = new Map>() export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { return () => { @@ -28,6 +35,106 @@ export namespace State { } } + export function register( + name: string, + root: () => string, + init: () => S, + dispose?: (state: Awaited) => Promise, + ) { + const getter = create(root, init, dispose) + + const wrappedGetter = () => { + const key = root() + let entries = namedRegistry.get(name) + if (!entries) { + entries = new Set() + namedRegistry.set(name, entries) + } + + const hasEntry = Array.from(entries).some((e) => e.key === key && e.init === init) + if (!hasEntry) { + entries.add({ + key, + init, + dispose, + }) + } + + return getter() + } + + return wrappedGetter + } + + /** + * Invalidates (disposes and removes) state entries registered under the given name. + * + * If the `name` ends with `:*`, it is treated as a wildcard pattern and all registered names + * that start with the given prefix (before the `:*`) will be invalidated. + * + * If a `key` is provided, only entries matching both the name and key will be invalidated. + * If `key` is omitted, all entries for the given name (or matching names, if using a wildcard) will be invalidated. + * + * @param {string} name - The registered name of the state to invalidate. Supports wildcard patterns (e.g., "foo:*"). + * @param {string} [key] - Optional key to further filter which state entries to invalidate. + * @returns {Promise} Resolves when all matching state entries have been invalidated. + * + * @example + * // Invalidate all state entries registered under "user" + * await State.invalidate("user"); + * + * // Invalidate only the state entry for "user" with a specific key + * await State.invalidate("user", "user:123"); + * + * // Invalidate all state entries for all names starting with "cache:" + * await State.invalidate("cache:*"); + */ + export async function invalidate(name: string, key?: string) { + const pattern = name.endsWith(":*") ? name.slice(0, -1) : null + if (pattern) { + const tasks: Promise[] = [] + for (const [registeredName] of namedRegistry) { + if (registeredName.startsWith(pattern)) { + tasks.push(invalidate(registeredName, key)) + } + } + await Promise.all(tasks) + return + } + + const entries = namedRegistry.get(name) + if (!entries) { + return + } + + log.info("invalidating state", { name, key: key ?? "all" }) + + const tasks: Promise[] = [] + for (const entry of entries) { + if (key && entry.key !== key) continue + + const keyRecords = recordsByKey.get(entry.key) + if (!keyRecords) continue + + const stateEntry = keyRecords.get(entry.init) + if (!stateEntry) continue + + if (stateEntry.dispose) { + const task = Promise.resolve(stateEntry.state) + .then((state) => stateEntry.dispose!(state)) + .catch((error) => { + log.error("Error while disposing state", { error, name, key: entry.key }) + }) + tasks.push(task) + } + + keyRecords.delete(entry.init) + } + + await Promise.all(tasks) + log.info("state invalidation completed", { name, key: key ?? "all" }) + } + export async function dispose(key: string) { const entries = recordsByKey.get(key) if (!entries) return @@ -57,7 +164,7 @@ export namespace State { tasks.push(task) } - entries.delete(key) + recordsByKey.delete(key) await Promise.all(tasks) disposalFinished = true log.info("state disposal completed", { key }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f8e6d0f75e1..cdda25ab4d3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -10,6 +10,7 @@ import { ModelsDev } from "./models" import { NamedError } from "../util/error" import { Auth } from "../auth" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Global } from "../global" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" @@ -211,246 +212,245 @@ export namespace Provider { }, } - const state = Instance.state(async () => { - using _ = log.time("state") - const config = await Config.get() - const database = await ModelsDev.get() - - const providers: { - [providerID: string]: { - source: Source - info: ModelsDev.Provider - getModel?: (sdk: any, modelID: string, options?: Record) => Promise - options: Record - } - } = {} - const models = new Map< - string, - { - providerID: string - modelID: string - info: ModelsDev.Model - language: LanguageModel - npm?: string - } - >() - const sdk = new Map() - // Maps `${provider}/${key}` to the provider’s actual model ID for custom aliases. - const realIdByKey = new Map() - - log.info("init") - - function mergeProvider( - id: string, - options: Record, - source: Source, - getModel?: (sdk: any, modelID: string, options?: Record) => Promise, - ) { - const provider = providers[id] - if (!provider) { - const info = database[id] - if (!info) return - if (info.api && !options["baseURL"]) options["baseURL"] = info.api - providers[id] = { - source, - info, - options, - getModel, + const state = State.register( + "provider", + () => Instance.directory, + async () => { + using _ = log.time("state") + const config = await Config.get() + const database = await ModelsDev.get() + + const providers: { + [providerID: string]: { + source: Source + info: ModelsDev.Provider + getModel?: (sdk: any, modelID: string, options?: Record) => Promise + options: Record + } + } = {} + const models = new Map< + string, + { + providerID: string + modelID: string + info: ModelsDev.Model + language: LanguageModel + npm?: string } - return + >() + const sdk = new Map() + // Maps `${provider}/${key}` to the provider’s actual model ID for custom aliases. + const realIdByKey = new Map() + + log.info("init") + + function mergeProvider( + id: string, + options: Record, + source: Source, + getModel?: (sdk: any, modelID: string, options?: Record) => Promise, + ) { + const provider = providers[id] + if (!provider) { + const info = database[id] + if (!info) return + if (info.api && !options["baseURL"]) options["baseURL"] = info.api + providers[id] = { + source, + info, + options, + getModel, + } + return + } + provider.options = mergeDeep(provider.options, options) + provider.source = source + provider.getModel = getModel ?? provider.getModel } - provider.options = mergeDeep(provider.options, options) - provider.source = source - provider.getModel = getModel ?? provider.getModel - } - const configProviders = Object.entries(config.provider ?? {}) - - // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot - if (database["github-copilot"]) { - const githubCopilot = database["github-copilot"] - database["github-copilot-enterprise"] = { - ...githubCopilot, - id: "github-copilot-enterprise", - name: "GitHub Copilot Enterprise", - // Enterprise uses a different API endpoint - will be set dynamically based on auth - api: undefined, + const configProviders = Object.entries(config.provider ?? {}) + + // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot + if (database["github-copilot"]) { + const githubCopilot = database["github-copilot"] + database["github-copilot-enterprise"] = { + ...githubCopilot, + id: "github-copilot-enterprise", + name: "GitHub Copilot Enterprise", + // Enterprise uses a different API endpoint - will be set dynamically based on auth + api: undefined, + } } - } - for (const [providerID, provider] of configProviders) { - const existing = database[providerID] - const parsed: ModelsDev.Provider = { - id: providerID, - npm: provider.npm ?? existing?.npm, - name: provider.name ?? existing?.name ?? providerID, - env: provider.env ?? existing?.env ?? [], - api: provider.api ?? existing?.api, - models: existing?.models ?? {}, - } + for (const [providerID, provider] of configProviders) { + const existing = database[providerID] + const parsed: ModelsDev.Provider = { + id: providerID, + npm: provider.npm ?? existing?.npm, + name: provider.name ?? existing?.name ?? providerID, + env: provider.env ?? existing?.env ?? [], + api: provider.api ?? existing?.api, + models: existing?.models ?? {}, + } - for (const [modelID, model] of Object.entries(provider.models ?? {})) { - const existing = parsed.models[model.id ?? modelID] - const name = iife(() => { - if (model.name) return model.name - if (model.id && model.id !== modelID) return modelID - return existing?.name ?? modelID - }) - const parsedModel: ModelsDev.Model = { - id: modelID, - name, - release_date: model.release_date ?? existing?.release_date, - attachment: model.attachment ?? existing?.attachment ?? false, - reasoning: model.reasoning ?? existing?.reasoning ?? false, - temperature: model.temperature ?? existing?.temperature ?? false, - tool_call: model.tool_call ?? existing?.tool_call ?? true, - cost: - !model.cost && !existing?.cost - ? { - input: 0, - output: 0, - cache_read: 0, - cache_write: 0, - } - : { - cache_read: 0, - cache_write: 0, - ...existing?.cost, - ...model.cost, - }, - options: { - ...existing?.options, - ...model.options, - }, - limit: model.limit ?? - existing?.limit ?? { - context: 0, - output: 0, - }, - modalities: model.modalities ?? - existing?.modalities ?? { - input: ["text"], - output: ["text"], + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const existing = parsed.models[modelID] + const parsedModel: ModelsDev.Model = { + id: modelID, + name: model.name ?? (model.id && model.id !== modelID ? modelID : (existing?.name ?? modelID)), + release_date: model.release_date ?? existing?.release_date, + attachment: model.attachment ?? existing?.attachment ?? false, + reasoning: model.reasoning ?? existing?.reasoning ?? false, + temperature: model.temperature ?? existing?.temperature ?? false, + tool_call: model.tool_call ?? existing?.tool_call ?? true, + cost: + !model.cost && !existing?.cost + ? { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + } + : { + cache_read: 0, + cache_write: 0, + ...existing?.cost, + ...model.cost, + }, + options: { + ...existing?.options, + ...model.options, }, - headers: model.headers, - provider: model.provider ?? existing?.provider, - } - if (model.id && model.id !== modelID) { - realIdByKey.set(`${providerID}/${modelID}`, model.id) + limit: model.limit ?? + existing?.limit ?? { + context: 0, + output: 0, + }, + modalities: model.modalities ?? + existing?.modalities ?? { + input: ["text"], + output: ["text"], + }, + headers: model.headers, + provider: model.provider ?? existing?.provider, + } + if (model.id && model.id !== modelID) { + realIdByKey.set(`${providerID}/${modelID}`, model.id) + } + parsed.models[modelID] = parsedModel } - parsed.models[modelID] = parsedModel + database[providerID] = parsed } - database[providerID] = parsed - } - const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? [])) - // load env - for (const [providerID, provider] of Object.entries(database)) { - if (disabled.has(providerID)) continue - const apiKey = provider.env.map((item) => process.env[item]).at(0) - if (!apiKey) continue - mergeProvider( - providerID, - // only include apiKey if there's only one potential option - provider.env.length === 1 ? { apiKey } : {}, - "env", - ) - } + const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? [])) + // load env + for (const [providerID, provider] of Object.entries(database)) { + if (disabled.has(providerID)) continue + const apiKey = provider.env.map((item) => process.env[item]).at(0) + if (!apiKey) continue + mergeProvider( + providerID, + // only include apiKey if there's only one potential option + provider.env.length === 1 ? { apiKey } : {}, + "env", + ) + } - // load apikeys - for (const [providerID, provider] of Object.entries(await Auth.all())) { - if (disabled.has(providerID)) continue - if (provider.type === "api") { - mergeProvider(providerID, { apiKey: provider.key }, "api") + // load apikeys + for (const [providerID, provider] of Object.entries(await Auth.all())) { + if (disabled.has(providerID)) continue + if (provider.type === "api") { + mergeProvider(providerID, { apiKey: provider.key }, "api") + } } - } - // load custom - for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { - if (disabled.has(providerID)) continue - const result = await fn(database[providerID]) - if (result && (result.autoload || providers[providerID])) { - mergeProvider(providerID, result.options ?? {}, "custom", result.getModel) + // load custom + for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { + if (disabled.has(providerID)) continue + const result = await fn(database[providerID]) + if (result && (result.autoload || providers[providerID])) { + mergeProvider(providerID, result.options ?? {}, "custom", result.getModel) + } } - } - for (const plugin of await Plugin.list()) { - if (!plugin.auth) continue - const providerID = plugin.auth.provider - if (disabled.has(providerID)) continue + for (const plugin of await Plugin.list()) { + if (!plugin.auth) continue + const providerID = plugin.auth.provider + if (disabled.has(providerID)) continue - // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise - let hasAuth = false - const auth = await Auth.get(providerID) - if (auth) hasAuth = true + // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise + let hasAuth = false + const auth = await Auth.get(providerID) + if (auth) hasAuth = true - // Special handling for github-copilot: also check for enterprise auth - if (providerID === "github-copilot" && !hasAuth) { - const enterpriseAuth = await Auth.get("github-copilot-enterprise") - if (enterpriseAuth) hasAuth = true - } + // Special handling for github-copilot: also check for enterprise auth + if (providerID === "github-copilot" && !hasAuth) { + const enterpriseAuth = await Auth.get("github-copilot-enterprise") + if (enterpriseAuth) hasAuth = true + } - if (!hasAuth) continue - if (!plugin.auth.loader) continue + if (!hasAuth) continue + if (!plugin.auth.loader) continue - // Load for the main provider if auth exists - if (auth) { - const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - mergeProvider(plugin.auth.provider, options ?? {}, "custom") - } + // Load for the main provider if auth exists + if (auth) { + const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) + mergeProvider(plugin.auth.provider, options ?? {}, "custom") + } - // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists - if (providerID === "github-copilot") { - const enterpriseProviderID = "github-copilot-enterprise" - if (!disabled.has(enterpriseProviderID)) { - const enterpriseAuth = await Auth.get(enterpriseProviderID) - if (enterpriseAuth) { - const enterpriseOptions = await plugin.auth.loader( - () => Auth.get(enterpriseProviderID) as any, - database[enterpriseProviderID], - ) - mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom") + // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists + if (providerID === "github-copilot") { + const enterpriseProviderID = "github-copilot-enterprise" + if (!disabled.has(enterpriseProviderID)) { + const enterpriseAuth = await Auth.get(enterpriseProviderID) + if (enterpriseAuth) { + const enterpriseOptions = await plugin.auth.loader( + () => Auth.get(enterpriseProviderID) as any, + database[enterpriseProviderID], + ) + mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom") + } } } } - } - // load config - for (const [providerID, provider] of configProviders) { - mergeProvider(providerID, provider.options ?? {}, "config") - } + // load config + for (const [providerID, provider] of configProviders) { + mergeProvider(providerID, provider.options ?? {}, "config") + } - for (const [providerID, provider] of Object.entries(providers)) { - const filteredModels = Object.fromEntries( - Object.entries(provider.info.models) - // Filter out blacklisted models - .filter( - ([modelID]) => - modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), - ) - // Filter out experimental models - .filter( - ([, model]) => - ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && - model.status !== "deprecated", - ), - ) - provider.info.models = filteredModels - - if (Object.keys(provider.info.models).length === 0) { - delete providers[providerID] - continue + for (const [providerID, provider] of Object.entries(providers)) { + const filteredModels = Object.fromEntries( + Object.entries(provider.info.models) + // Filter out blacklisted models + .filter( + ([modelID]) => + modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), + ) + // Filter out experimental models + .filter( + ([, model]) => + ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && + model.status !== "deprecated", + ), + ) + provider.info.models = filteredModels + + if (Object.keys(provider.info.models).length === 0) { + delete providers[providerID] + continue + } + log.info("found", { providerID }) } - log.info("found", { providerID }) - } - return { - models, - providers, - sdk, - realIdByKey, - } - }) + return { + models, + providers, + sdk, + realIdByKey, + } + }, + ) export async function list() { return state().then((state) => state.providers) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2dee6c915f7..393217b9fa9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -40,6 +40,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" import { TuiEvent } from "@/cli/cmd/tui/event" import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" +import { isConfigHotReloadEnabled } from "../config/hot-reload" const ERRORS = { 400: { @@ -76,7 +77,23 @@ function errors(...codes: number[]) { export namespace Server { const log = Log.create({ service: "server" }) + // Remember last config update sections per directory to enrich subsequent TUI toasts. + // Entries auto-expire after a short window. + const LastConfigUpdate: Map = new Map() + // Periodically clean up stale entries from LastConfigUpdate + setInterval(() => { + const now = Date.now() + for (const [dir, entry] of LastConfigUpdate.entries()) { + if (now - entry.at > 60_000) { + LastConfigUpdate.delete(dir) + } + } + }, 60_000) + + function rememberConfigUpdate(directory: string, scope: "project" | "global", sections: string[]) { + LastConfigUpdate.set(directory, { scope, sections, at: Date.now() }) + } export const Event = { Connected: Bus.event("server.connected", z.object({})), } @@ -184,8 +201,57 @@ export namespace Server { validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - await Config.update(config) - return c.json(config) + const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project" + const directory = Instance.directory + + const result = await Config.update({ + scope, + update: config, + directory, + }) + + const publishDiff = result.diffForPublish + const hotReloadEnabled = isConfigHotReloadEnabled() + const sections = Object.keys(publishDiff).filter((k) => (publishDiff as any)[k] === true) + // Remember sections for toast enrichment regardless of hot reload mode + rememberConfigUpdate(directory, scope, sections) + + if (hotReloadEnabled && scope === "project") { + await Bus.publish(Config.Event.Updated, { + scope, + directory, + refreshed: true, + before: result.before, + after: result.after, + diff: publishDiff, + }) + } + if (hotReloadEnabled && scope === "global") { + const publishErrors = await Instance.forEach(async (dir) => { + await Bus.publish(Config.Event.Updated, { + scope, + directory: dir, + refreshed: true, + before: result.before, + after: result.after, + diff: publishDiff, + }) + rememberConfigUpdate(dir, scope, sections) + }) + + if (publishErrors.length > 0) { + log.error("config.publish.failure", { scope, errors: publishErrors }) + const details = publishErrors + .map((failure) => { + const message = failure.error instanceof Error ? failure.error.message : String(failure.error) + return `${failure.directory}: ${message}` + }) + .join("; ") + throw new Error(`Failed to notify directories: ${details}`) + } + } + + return c.json(result.after) }, ) .get( @@ -1637,7 +1703,36 @@ export namespace Server { }), validator("json", TuiEvent.ToastShow.properties), async (c) => { - await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + const payload = c.req.valid("json") + // Enrich config save toasts that lack detail, e.g. "Saved global config -> undefined". + try { + const directory = Instance.directory + const match = payload.message.match(/Saved (global|project) config/i) + if (match) { + const scope = (match[1] as string).toLowerCase() as "global" | "project" + const now = Date.now() + const isFresh = (ts: number) => now - ts < 10_000 + + let candidate = LastConfigUpdate.get(directory) + if (!candidate || !isFresh(candidate.at) || candidate.scope !== scope) { + // Fallback: find the freshest entry with the same scope + candidate = Array.from(LastConfigUpdate.values()) + .filter((e) => e.scope === scope && isFresh(e.at)) + .sort((a, b) => b.at - a.at)[0] + } + + if (candidate) { + const sectionText = candidate.sections.length > 0 ? candidate.sections.join(", ") : "no changes" + // Replace generic arrow-suffix if present, otherwise rebuild the message + if (/->\s*undefined$/i.test(payload.message)) { + payload.message = payload.message.replace(/->\s*undefined$/i, `-> ${sectionText}`) + } else { + payload.message = `Saved ${candidate.scope} config -> ${sectionText}` + } + } + } + } catch {} + await Bus.publish(TuiEvent.ToastShow, payload) return c.json(true) }, ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f7888761ab2..68f2c39f735 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -13,6 +13,7 @@ import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" import { Config } from "../config/config" +import { State } from "../project/state" import path from "path" import { type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" @@ -22,34 +23,38 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" export namespace ToolRegistry { - export const state = Instance.state(async () => { - const custom = [] as Tool.Info[] - const glob = new Bun.Glob("tool/*.{js,ts}") + export const state = State.register( + "tool-registry", + () => Instance.directory, + async () => { + const custom = [] as Tool.Info[] + const glob = new Bun.Glob("tool/*.{js,ts}") - for (const dir of await Config.directories()) { - for await (const match of glob.scan({ - cwd: dir, - absolute: true, - followSymlinks: true, - dot: true, - })) { - const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + for (const dir of await Config.directories()) { + for await (const match of glob.scan({ + cwd: dir, + absolute: true, + followSymlinks: true, + dot: true, + })) { + const namespace = path.basename(match, path.extname(match)) + const mod = await import(match) + for (const [id, def] of Object.entries(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + } } } - } - const plugins = await Plugin.list() - for (const plugin of plugins) { - for (const [id, def] of Object.entries(plugin.tool ?? {})) { - custom.push(fromPlugin(id, def)) + const plugins = await Plugin.list() + for (const plugin of plugins) { + for (const [id, def] of Object.entries(plugin.tool ?? {})) { + custom.push(fromPlugin(id, def)) + } } - } - return { custom } - }) + return { custom } + }, + ) function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 967972842f5..10eac084013 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,11 +1,65 @@ import { test, expect } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" +import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" +async function withHotReloadFlag(value: string | undefined, fn: () => Promise) { + const previous = process.env.OPENCODE_CONFIG_HOT_RELOAD + if (typeof value === "string") { + process.env.OPENCODE_CONFIG_HOT_RELOAD = value + } else { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + } + try { + return await fn() + } finally { + if (previous === undefined) { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + } else { + process.env.OPENCODE_CONFIG_HOT_RELOAD = previous + } + } +} + +function scopedPluginFixture() { + return tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(dir, "package.json"), + JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), + ) + + await Bun.write( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@scope/plugin", + version: "1.0.0", + type: "module", + main: "./index.js", + }, + null, + 2, + ), + ) + + await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), + ) + }, + }) +} + test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -214,6 +268,34 @@ test("handles agent configuration", async () => { }) }) +test("preserves scoped plugin specifiers and resolves relative plugin paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "local-plugins") + await fs.mkdir(pluginDir, { recursive: true }) + const pluginFile = path.join(pluginDir, "custom.ts") + await Bun.write(pluginFile, "export default {}") + await Bun.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["@promethean-os/opencode-openai-codex-auth", "./local-plugins/custom.ts"], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("@promethean-os/opencode-openai-codex-auth") + const pluginFileUrl = pathToFileURL(path.join(tmp.path, "local-plugins", "custom.ts")).href + expect(config.plugin).toContain(pluginFileUrl) + }, + }) +}) + test("handles command configuration", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -333,9 +415,9 @@ test("updates config and writes to file", async () => { directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } - await Config.update(newConfig as any) + const result = await Config.update({ update: newConfig as any }) - const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text()) + const writtenConfig = JSON.parse(await Bun.file(result.filepath).text()) expect(writtenConfig.model).toBe("updated/model") }, }) @@ -352,54 +434,73 @@ test("gets config directories", async () => { }) }) -test("resolves scoped npm plugins in config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") - await fs.mkdir(pluginDir, { recursive: true }) +test("does not rewrite scoped npm plugins even when hot reload is enabled", async () => { + await withHotReloadFlag("true", async () => { + await using tmp = await scopedPluginFixture() - await Bun.write( - path.join(dir, "package.json"), - JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), - ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("@scope/plugin") + }, + }) + }) +}) - await Bun.write( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@scope/plugin", - version: "1.0.0", - type: "module", - main: "./index.js", - }, - null, - 2, - ), - ) +test("keeps scoped npm plugin identifiers when hot reload is disabled", async () => { + await withHotReloadFlag(undefined, async () => { + await using tmp = await scopedPluginFixture() - await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("@scope/plugin") + }, + }) + }) +}) +test("appends plugins discovered from directories after merging config files", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "plugin"), { recursive: true }) + await Bun.write(path.join(dir, "plugin", "custom.ts"), "export const plugin = {}") await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["global-plugin"], + }), ) }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - const pluginEntries = config.plugin ?? [] - - const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href - const expected = import.meta.resolve("@scope/plugin", baseUrl) - - expect(pluginEntries.includes(expected)).toBe(true) - - const scopedEntry = pluginEntries.find((entry) => entry === expected) - expect(scopedEntry).toBeDefined() - expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true) + await using workspace = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["local-plugin"], + }), + ) }, }) + + const previousGlobalConfig = Global.Path.config + ;(Global.Path as any).config = globalTmp.path + try { + await Instance.provide({ + directory: workspace.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toEqual(["local-plugin", `file://${path.join(globalTmp.path, "plugin", "custom.ts")}`]) + }, + }) + } finally { + ;(Global.Path as any).config = previousGlobalConfig + } }) diff --git a/packages/opencode/test/config/hot-reload.test.ts b/packages/opencode/test/config/hot-reload.test.ts new file mode 100644 index 00000000000..77b608aa8aa --- /dev/null +++ b/packages/opencode/test/config/hot-reload.test.ts @@ -0,0 +1,368 @@ +import { test, expect } from "bun:test" +import os from "os" +import path from "path" +import fs from "fs/promises" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Bus } from "../../src/bus" +import { Server } from "../../src/server/server" +import { Global } from "../../src/global" +import { ConfigInvalidation } from "../../src/config/invalidation" + +async function withFreshGlobalPath(fn: (globalRoot: string) => Promise) { + const originalGlobalConfig = Global.Path.config + const globalRoot = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-global-")), "config") + ;(Global.Path as any).config = globalRoot + await fs.mkdir(globalRoot, { recursive: true }) + try { + return await fn(globalRoot) + } finally { + ;(Global.Path as any).config = originalGlobalConfig + await fs.rm(globalRoot, { recursive: true, force: true }) + } +} + +async function createWorkspace(prefix?: string) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix ?? "opencode-test-")) + await fs.mkdir(path.join(tmpDir, ".git"), { recursive: true }) + await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main") + return tmpDir +} + +async function patchConfig(directory: string, body: Record, scope: "project" | "global" = "project") { + const url = new URL("/config", "http://localhost") + url.searchParams.set("scope", scope) + url.searchParams.set("directory", directory) + + return Server.App().fetch( + new Request(url.toString(), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) +} + +async function getConfig(directory: string) { + const url = new URL("/config", "http://localhost") + url.searchParams.set("directory", directory) + return Server.App().fetch( + new Request(url.toString(), { + method: "GET", + }), + ) +} + +async function subscribeWithContext(directory: string, callback: (event: any) => Promise | void) { + return Instance.provide({ + directory, + fn: async () => { + return Bus.subscribe(Config.Event.Updated, async (event) => { + const targetDirectory = event.properties.directory ?? process.cwd() + return Instance.provide({ + directory: targetDirectory, + fn: async () => { + await callback(event) + }, + }) + }) + }, + }) +} + +async function ensureInstance(directory: string) { + await Instance.provide({ + directory, + init: InstanceBootstrap, + fn: async () => { + await Config.get() + }, + }) +} + +async function cleanup(directories: string[]) { + await Instance.disposeAll() + for (const dir of directories) { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +await Instance.disposeAll() + +test("config hot reload updates without full dispose", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const directory = await createWorkspace("hot-reload-") + try { + await withFreshGlobalPath(async () => { + await Instance.provide({ + directory, + fn: async () => { + const before = await Config.get() + expect(before.model).toBeUndefined() + + const result = await Config.update({ + scope: "project", + update: { model: "anthropic/claude-3-5-sonnet" }, + directory, + }) + + expect(result.after.model).toBe("anthropic/claude-3-5-sonnet") + + const configPath = path.join(directory, ".opencode", "opencode.jsonc") + expect(await Bun.file(configPath).exists()).toBe(true) + + const content = await Bun.file(configPath).text() + expect(content).toContain("anthropic/claude-3-5-sonnet") + expect(result.filepath).toBe(configPath) + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([directory]) + } +}) + +test("config hot reload with feature flag disabled uses full dispose", async () => { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + const directory = await createWorkspace("hot-reload-disabled-") + try { + await withFreshGlobalPath(async () => { + await Instance.provide({ + directory, + fn: async () => { + const result = await Config.update({ + scope: "project", + update: { model: "anthropic/claude-3-5-sonnet" }, + directory, + }) + + expect(result.after.model).toBe("anthropic/claude-3-5-sonnet") + }, + }) + }) + } finally { + await cleanup([directory]) + } +}) + +test("GET /config returns cached view when hot reload is disabled", async () => { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + const directory = await createWorkspace("hot-reload-get-disabled-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(directory) + + const patchResponse = await patchConfig(directory, { model: "cached-model" }, "project") + expect(patchResponse.status).toBe(200) + + const response = await getConfig(directory) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.model).toBeUndefined() + + const configPath = path.join(directory, ".opencode", "opencode.jsonc") + const fileContent = await Bun.file(configPath).text() + expect(fileContent).toContain("cached-model") + }) + } finally { + await cleanup([directory]) + } +}) + +test("global updates propagate despite local overrides", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const writer = await createWorkspace("global-writer-") + const observer = await createWorkspace("global-observer-") + try { + await withFreshGlobalPath(async () => { + await fs.mkdir(path.join(writer, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(writer, ".opencode", "opencode.jsonc"), JSON.stringify({ model: "local-model" })) + + await ensureInstance(writer) + await ensureInstance(observer) + + const response = await patchConfig(writer, { model: "global-model" }, "global") + expect(response.status).toBe(200) + + await Instance.provide({ + directory: observer, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("global-model") + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([writer, observer]) + } +}) + +test("custom XDG_CONFIG_HOME is honored for global updates", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const workspace = await createWorkspace("xdg-config-") + try { + await withFreshGlobalPath(async () => { + const xdgBase = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-xdg-")) + const customConfigRoot = path.join(xdgBase, "opencode") + const previousConfigPath = Global.Path.config + try { + ;(Global.Path as any).config = customConfigRoot + await fs.mkdir(customConfigRoot, { recursive: true }) + await ensureInstance(workspace) + + const response = await patchConfig(workspace, { model: "xdg-model" }, "global") + expect(response.status).toBe(200) + + const fileContent = await Bun.file(path.join(customConfigRoot, "opencode.jsonc")).text() + expect(fileContent).toContain("xdg-model") + + await Instance.provide({ + directory: workspace, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("xdg-model") + }, + }) + } finally { + ;(Global.Path as any).config = previousConfigPath + await fs.rm(xdgBase, { recursive: true, force: true }) + } + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([workspace]) + } +}) + +test("event subscriber sees refreshed config before targeted invalidations", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const directory = await createWorkspace("event-subscriber-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(directory) + + const response = await patchConfig(directory, { model: "event-model" }, "global") + expect(response.status).toBe(200) + + await Instance.provide({ + directory, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("event-model") + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([directory]) + } +}) + +test("global fan-out surfaces aggregated publish errors", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const sender = await createWorkspace("fanout-sender-") + const target = await createWorkspace("fanout-target-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(sender) + await ensureInstance(target) + + const unsub = await subscribeWithContext(target, (event) => { + if (event.properties.directory === target) { + throw new Error("publish failure") + } + }) + + const response = await patchConfig(sender, { model: "fanout-model" }, "global") + expect(response.status).toBe(500) + + const json = await response.json() + const message = String((json && (json.message ?? json.data?.message)) ?? "") + expect(message).toContain("Failed to notify directories") + + await Instance.provide({ + directory: target, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("fanout-model") + }, + }) + + unsub() + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([sender, target]) + } +}) + +test("project updates remain scoped to the initiator", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const writer = await createWorkspace("project-writer-") + const observer = await createWorkspace("project-observer-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(writer) + await ensureInstance(observer) + + await patchConfig(writer, { model: "global-model" }, "global") + + const response = await patchConfig(writer, { model: "project-model" }, "project") + expect(response.status).toBe(200) + + await Instance.provide({ + directory: writer, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("project-model") + }, + }) + + await Instance.provide({ + directory: observer, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("global-model") + }, + }) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([writer, observer]) + } +}) + +test("theme-only global updates avoid unrelated invalidations", async () => { + process.env.OPENCODE_CONFIG_HOT_RELOAD = "true" + const workspace = await createWorkspace("theme-only-") + try { + await withFreshGlobalPath(async () => { + await ensureInstance(workspace) + const invalidations: string[] = [] + const originalInvalidate = Instance.invalidate + ;(Instance as any).invalidate = async (name: string) => { + invalidations.push(name) + await originalInvalidate(name) + } + + try { + await ConfigInvalidation.apply({ + scope: "global", + directory: workspace, + diff: { theme: true }, + }) + } finally { + ;(Instance as any).invalidate = originalInvalidate + } + + const nonConfigInvalidations = invalidations.filter((name) => name !== "config") + expect(nonConfigInvalidations).toEqual(["theme"]) + }) + } finally { + delete process.env.OPENCODE_CONFIG_HOT_RELOAD + await cleanup([workspace]) + } +}) diff --git a/packages/opencode/test/config/write.test.ts b/packages/opencode/test/config/write.test.ts new file mode 100644 index 00000000000..61a168795bc --- /dev/null +++ b/packages/opencode/test/config/write.test.ts @@ -0,0 +1,75 @@ +import { test, expect } from "bun:test" +import os from "os" +import path from "path" +import fs from "fs/promises" +import { parse as parseJsonc, type ParseError } from "jsonc-parser" +import { writeConfigFile } from "../../src/config/write" +import { Config } from "../../src/config/config" + +test("writeConfigFile preserves JSONC comments without triggering fallback", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jsonc-")) + const filepath = path.join(dir, "opencode.jsonc") + const original = `{ + // keep me + "model": "before" +} +` + await Bun.write(filepath, original) + + try { + await expect( + writeConfigFile( + filepath, + { + model: "after", + }, + original, + ), + ).resolves.toBeUndefined() + + const updated = await Bun.file(filepath).text() + expect(updated).toContain("// keep me") + expect(updated).toContain(`"model": "after"`) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +}) + +test("writeConfigFile incremental edits keep JSONC valid", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jsonc-incremental-")) + const filepath = path.join(dir, "opencode.jsonc") + const original = `{ + // settings + "model": "anthropic/old", + "theme": "light", + "agent": { + "build": { + "model": "anthropic/old" + } + } +} +` + await Bun.write(filepath, original) + + const nextConfig = Config.Info.parse({ + $schema: "https://opencode.ai/schema/config.json", + model: "anthropic/new", + theme: "dark", + agent: { + build: { model: "anthropic/new" }, + plan: { model: "anthropic/new" }, + }, + }) + + try { + await writeConfigFile(filepath, nextConfig, original) + const updated = await Bun.file(filepath).text() + const errors: ParseError[] = [] + parseJsonc(updated, errors, { allowTrailingComma: true }) + expect(errors.length).toBe(0) + expect(updated).toContain("// settings") + expect(updated).toContain(`"theme": "dark"`) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +}) diff --git a/specs/config-spec.md b/specs/config-spec.md new file mode 100644 index 00000000000..53ad215b30c --- /dev/null +++ b/specs/config-spec.md @@ -0,0 +1,100 @@ +# PATCH /config Spec + +Provides concrete steps clients can follow to update runtime configuration without restarting the server. This section focuses on `PATCH /config`, but also highlights the companion `GET /config` for verification. + +## Purpose + +- Enables project- or global-scoped config updates that persist to disk. +- Returns the merged runtime configuration so clients immediately know the active state. +- Triggers targeted invalidation and publishes `config.updated` events after the response so other components can react. + +## Endpoint + +- **URL:** `/config` +- **Method:** `PATCH` +- **Query parameters:** + - `scope=project|global` (optional, defaults to `project`) + +## Request body + +Must satisfy the `Config.Info` schema (see `packages/opencode/src/config/config.ts`). Examples of supported keys: + +```jsonc +{ + "username": "new-name", + "agent": { + "build": { + "model": "anthropic/claude-3", + }, + }, + "share": "manual", +} +``` + +- Partial updates are merged deep into the existing configuration; unspecified keys inherit their current values. +- JSONC comments are preserved when writing back to disk. +- The server normalizes defaults (e.g., ensures `agent`, `mode`, `plugin`, `keybinds` exist) before persisting. + +## Behavior + +1. **Target file selection** + - `project` scope: the first existing file from `./.opencode/opencode.jsonc`, `./.opencode/opencode.json`, `./opencode.jsonc`, `./opencode.json`. If none exist, a new `./.opencode/opencode.jsonc` is created. + - `global` scope: always `~/.config/opencode/opencode.jsonc`; directories are created as needed. +2. Acquire a file lock (30s timeout) and backup the current file before modifications. +3. Merge the request payload with the target file’s content, validate against the schema, normalize defaults, and persist while preserving comments. +4. On success, delete the backup; on failure, restore the backup and raise `ConfigUpdateError`. +5. When `OPENCODE_CONFIG_HOT_RELOAD=true`, invalidate the registered `config` state so the next `Config.get()` reflects the update and powers hot reloads without restarting the server. If the flag is unset/false, the cached config intentionally remains in memory and `GET /config` continues to return the pre-patch view until the process restarts. +6. When hot reload is enabled, publish `config.updated` events via `Bus.publish` and, for project scope, only for the current directory (global scope notifies every directory tracked by `Instance.forEach`). With the flag disabled, events are suppressed so legacy integrations see the old cache. + +## Response + +- **200 OK** – returns the merged runtime config after applying the patch. +- Clients can immediately call `GET /config` (no query params) to double-check, or rely on the response body for the canonical view. + - Example response (truncated): + ```jsonc + { + "username": "new-name", + "agent": { ... }, + "share": "manual", + ... + } + ``` + +## Error cases + +- `400` – validation failures (body doesn’t match `Config.Info`, invalid plugin/agent entries, missing fields required by custom LSPs). +- Any other failure returns `500` with an error object that includes `data` and `errors` fields when triggered by `NamedError`. + +## Client workflow (curl example) + +```bash +SERVER=http://10.0.2.100:3366 + +# 1. inspect current config +curl "$SERVER/config" | jq . + +# 2. update username and sharing mode +curl -X PATCH "$SERVER/config" \ + -H 'Content-Type: application/json' \ + -d '{"username":"config-hot-reload-test","share":"manual"}' \ + | jq . + +# 3. verify changes persist +curl "$SERVER/config" | jq . +``` + +- With `OPENCODE_CONFIG_HOT_RELOAD=true`, no server restart is required because `Config.update` invalidates cached state and `Config.get()` reloads the merged data; when the flag is unset, plan for a restart before `GET /config` reflects the disk change. +- After the PATCH returns, subscribers (e.g., UI, CLI tooling) can listen for `config.updated` to refresh views or rerun initialization logic whenever hot reload is enabled. + +## Notes for integrators + +- If your integration maintains its own config cache, refresh it when you observe the `config.updated` event. +- Use `scope=global` when the update must affect every project directory; global updates are applied once and broadcast to all tracked directories. +- When calling from scripts, prefer `jq` or equivalent to diff the before/after payload, since the server returns the merged view. + +### Feature flag: `OPENCODE_CONFIG_HOT_RELOAD` + +- Default state: unset/false, which matches the legacy behavior where PATCH persists to disk but in-memory caches (and `GET /config`) are not refreshed until a restart. +- Hot reload path: set `OPENCODE_CONFIG_HOT_RELOAD=true` **before starting** the server or CLI to enable on-the-fly invalidations and `config.updated` bus events. +- Backward-compatibility check: with the flag unset, run the curl workflow above (`GET` → `PATCH` → `GET`) and confirm the final `GET` still returns the pre-patch configuration while the on-disk file has the new content. +- Regression test expectation: with the flag enabled, run `bun --cwd packages/opencode test config/hot-reload.test.ts` (or your integration-specific suite) to verify targeted invalidations and event fan-out continue to work.