From b5d9b4c902333b050431249520617dac0b59c84c Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Wed, 21 Jan 2026 11:11:23 -0500 Subject: [PATCH 1/3] feat: hot-reload config files for MCP server changes Add automatic config file watching to FileWatcher that triggers Instance.dispose() when opencode.json, opencode.jsonc, or config.json changes. This enables hot-reloading of MCP servers and other config without restarting the server. Changes: - Watch project root, .opencode/, ~/.opencode/, and ~/.config/opencode/ - Per-instance debounce (500ms) and in-flight guard to prevent storms - Works for all projects (not just git repos) - Add OPENCODE_DISABLE_CONFIG_WATCHER flag to opt out --- packages/opencode/src/file/watcher.ts | 191 ++++++++++++++++++++++---- packages/opencode/src/flag/flag.ts | 1 + 2 files changed, 166 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index c4a4747777e..2503195283c 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -5,6 +5,8 @@ import { Instance } from "../project/instance" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" +import { Global } from "../global" +import { Filesystem } from "@/util/filesystem" import path from "path" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" @@ -16,12 +18,15 @@ import { Flag } from "@/flag/flag" import { readdir } from "fs/promises" const SUBSCRIBE_TIMEOUT_MS = 10_000 +const CONFIG_DEBOUNCE_MS = 500 declare const OPENCODE_LIBC: string | undefined export namespace FileWatcher { const log = Log.create({ service: "file.watcher" }) + const CONFIG_FILES = new Set(["opencode.json", "opencode.jsonc", "config.json"]) + export const Event = { Updated: BusEvent.define( "file.watcher.updated", @@ -30,6 +35,13 @@ export namespace FileWatcher { event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), }), ), + ConfigChanged: BusEvent.define( + "file.watcher.config.changed", + z.object({ + file: z.string(), + event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), + }), + ), } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { @@ -44,9 +56,93 @@ export namespace FileWatcher { } }) + // Per-instance state for config watcher debouncing and in-flight guard + interface ConfigWatcherState { + disposeInFlight: boolean + debounceTimer: ReturnType | undefined + } + + function handleConfigChange(file: string, event: "add" | "change" | "unlink", configState: ConfigWatcherState) { + log.info("config file changed", { file, event }) + Bus.publish(Event.ConfigChanged, { file, event }) + + // Debounce and guard against overlapping dispose calls + if (configState.debounceTimer) { + clearTimeout(configState.debounceTimer) + } + + configState.debounceTimer = setTimeout(async () => { + if (configState.disposeInFlight) { + log.debug("dispose already in flight, skipping") + return + } + + configState.disposeInFlight = true + try { + log.info("reloading instance due to config change") + await Instance.dispose() + } catch (error) { + log.error("failed to dispose instance", { error }) + } finally { + configState.disposeInFlight = false + } + }, CONFIG_DEBOUNCE_MS) + } + + // Check if a file path is at the root level of the watched directory + function isRootLevelFile(filePath: string, watchedDir: string): boolean { + const rel = path.relative(watchedDir, filePath) + // Root level = no path separators in relative path + return rel.length > 0 && !rel.includes(path.sep) && !rel.startsWith("..") + } + + // Create callback for config file watching (filters to config files only) + function createConfigCallback(watchedDir: string, configState: ConfigWatcherState): ParcelWatcher.SubscribeCallback { + return (err, evts) => { + if (err) return + for (const evt of evts) { + const filename = path.basename(evt.path) + // Only process config files at the root of the watched directory + if (!isRootLevelFile(evt.path, watchedDir)) continue + if (!CONFIG_FILES.has(filename)) continue + + const event = evt.type === "create" ? "add" : evt.type === "update" ? "change" : "unlink" + handleConfigChange(evt.path, event, configState) + } + } + } + + // Subscribe to a directory for config file changes + async function subscribeConfigDir( + w: typeof import("@parcel/watcher"), + dir: string, + backend: "windows" | "fs-events" | "inotify", + configState: ConfigWatcherState, + ): Promise { + try { + const pending = w.subscribe(dir, createConfigCallback(dir, configState), { + // Ignore subdirectories - only watch root level files + // Note: callback also filters, this is defense in depth + ignore: ["*/**"], + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe to config directory", { error: err, dir }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) { + log.info("watching config directory", { dir }) + } + return sub + } catch (error) { + log.error("failed to watch config directory", { error, dir }) + return undefined + } + } + const state = Instance.state( async () => { - if (Instance.project.vcs !== "git") return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -63,6 +159,44 @@ export namespace FileWatcher { const w = watcher() if (!w) return {} + const subs: ParcelWatcher.AsyncSubscription[] = [] + const cfgIgnores = cfg.watcher?.ignore ?? [] + + // Per-instance config watcher state + const configState: ConfigWatcherState = { + disposeInFlight: false, + debounceTimer: undefined, + } + + // --- Config file watching (always enabled unless explicitly disabled) --- + if (!Flag.OPENCODE_DISABLE_CONFIG_WATCHER) { + // Watch project directory for config files + const projectSub = await subscribeConfigDir(w, Instance.directory, backend, configState) + if (projectSub) subs.push(projectSub) + + // Watch .opencode subdirectory if it exists + const dotOpencode = path.join(Instance.directory, ".opencode") + if (await Filesystem.exists(dotOpencode)) { + const dotSub = await subscribeConfigDir(w, dotOpencode, backend, configState) + if (dotSub) subs.push(dotSub) + } + + // Watch ~/.opencode directory if it exists (user home) + const homeOpencode = path.join(Global.Path.home, ".opencode") + if (homeOpencode !== dotOpencode && (await Filesystem.exists(homeOpencode))) { + const homeSub = await subscribeConfigDir(w, homeOpencode, backend, configState) + if (homeSub) subs.push(homeSub) + } + + // Watch global config directory (if different from project and home) + const globalConfig = Global.Path.config + if (globalConfig !== Instance.directory && globalConfig !== homeOpencode) { + const globalSub = await subscribeConfigDir(w, globalConfig, backend, configState) + if (globalSub) subs.push(globalSub) + } + } + + // --- General file watching (experimental, git-only) --- const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return for (const evt of evts) { @@ -72,9 +206,6 @@ export namespace FileWatcher { } } - const subs: ParcelWatcher.AsyncSubscription[] = [] - const cfgIgnores = cfg.watcher?.ignore ?? [] - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { const pending = w.subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], @@ -88,38 +219,46 @@ export namespace FileWatcher { if (sub) subs.push(sub) } - const vcsDir = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => path.resolve(Instance.worktree, x.trim())) - .catch(() => undefined) - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const gitDirContents = await readdir(vcsDir).catch(() => []) - const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = w.subscribe(vcsDir, subscribe, { - ignore: ignoreList, - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to vcsDir", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) + // --- Git HEAD watching (git-only) --- + if (Instance.project.vcs === "git") { + const vcsDir = await $`git rev-parse --git-dir` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => path.resolve(Instance.worktree, x.trim())) + .catch(() => undefined) + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const gitDirContents = await readdir(vcsDir).catch(() => []) + const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") + const pending = w.subscribe(vcsDir, subscribe, { + ignore: ignoreList, + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe to vcsDir", { error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) subs.push(sub) + } } - return { subs } + return { subs, configState } }, async (state) => { + // Clean up debounce timer + if (state.configState?.debounceTimer) { + clearTimeout(state.configState.debounceTimer) + state.configState.debounceTimer = undefined + } if (!state.subs) return await Promise.all(state.subs.map((sub) => sub?.unsubscribe())) }, ) export function init() { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) { + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER && Flag.OPENCODE_DISABLE_CONFIG_WATCHER) { return } state() diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index d106c2d86e9..ffc98c1b2b4 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -33,6 +33,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") + export const OPENCODE_DISABLE_CONFIG_WATCHER = truthy("OPENCODE_DISABLE_CONFIG_WATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") From fc5e79954c790cced90a81ea8c8526a4f9ae2ccd Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Wed, 21 Jan 2026 11:21:27 -0500 Subject: [PATCH 2/3] feat: hot-reload config files for MCP server changes - Watch config files (opencode.json, opencode.jsonc, config.json) in: - Project directory - .opencode subdirectory - ~/.opencode (user home) - ~/.config/opencode (global config) - Debounce config changes (500ms) to prevent reload storms - Reset Config.global cache before Instance.dispose() - Emit global.disposed event to notify TUI/web clients to refresh - Add OPENCODE_EXPERIMENTAL_CONFIG_WATCHER flag to opt-in - Use createRequire for browser conditions compatibility --- packages/opencode/src/file/watcher.ts | 113 +++---- packages/opencode/src/flag/flag.ts | 2 +- .../opencode/test/file/config-watcher.test.ts | 310 ++++++++++++++++++ packages/opencode/test/preload.ts | 3 + 4 files changed, 365 insertions(+), 63 deletions(-) create mode 100644 packages/opencode/test/file/config-watcher.test.ts diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 2503195283c..0674c0338ff 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,5 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" +import { GlobalBus } from "@/bus/global" import z from "zod" import { Instance } from "../project/instance" import { Log } from "../util/log" @@ -16,6 +17,7 @@ import type ParcelWatcher from "@parcel/watcher" import { $ } from "bun" import { Flag } from "@/flag/flag" import { readdir } from "fs/promises" +import { createRequire } from "module" const SUBSCRIBE_TIMEOUT_MS = 10_000 const CONFIG_DEBOUNCE_MS = 500 @@ -44,6 +46,9 @@ export namespace FileWatcher { ), } + // Create require function that works in browser conditions + const require = createRequire(import.meta.url) + const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { const binding = require( @@ -56,73 +61,70 @@ export namespace FileWatcher { } }) - // Per-instance state for config watcher debouncing and in-flight guard interface ConfigWatcherState { - disposeInFlight: boolean - debounceTimer: ReturnType | undefined + inFlight: boolean + timer: ReturnType | undefined } - function handleConfigChange(file: string, event: "add" | "change" | "unlink", configState: ConfigWatcherState) { + function handleConfigChange(file: string, event: "add" | "change" | "unlink", state: ConfigWatcherState) { log.info("config file changed", { file, event }) Bus.publish(Event.ConfigChanged, { file, event }) - // Debounce and guard against overlapping dispose calls - if (configState.debounceTimer) { - clearTimeout(configState.debounceTimer) - } + if (state.timer) clearTimeout(state.timer) - configState.debounceTimer = setTimeout(async () => { - if (configState.disposeInFlight) { + state.timer = setTimeout(async () => { + if (state.inFlight) { log.debug("dispose already in flight, skipping") return } - configState.disposeInFlight = true + state.inFlight = true try { + Config.global.reset() log.info("reloading instance due to config change") await Instance.dispose() + GlobalBus.emit("event", { + directory: "global", + payload: { + type: "global.disposed", + properties: {}, + }, + }) } catch (error) { log.error("failed to dispose instance", { error }) } finally { - configState.disposeInFlight = false + state.inFlight = false } }, CONFIG_DEBOUNCE_MS) } - // Check if a file path is at the root level of the watched directory - function isRootLevelFile(filePath: string, watchedDir: string): boolean { - const rel = path.relative(watchedDir, filePath) - // Root level = no path separators in relative path + function isRootLevel(file: string, dir: string): boolean { + const rel = path.relative(dir, file) return rel.length > 0 && !rel.includes(path.sep) && !rel.startsWith("..") } - // Create callback for config file watching (filters to config files only) - function createConfigCallback(watchedDir: string, configState: ConfigWatcherState): ParcelWatcher.SubscribeCallback { + function createConfigCallback(dir: string, state: ConfigWatcherState): ParcelWatcher.SubscribeCallback { return (err, evts) => { if (err) return for (const evt of evts) { const filename = path.basename(evt.path) - // Only process config files at the root of the watched directory - if (!isRootLevelFile(evt.path, watchedDir)) continue + if (!isRootLevel(evt.path, dir)) continue if (!CONFIG_FILES.has(filename)) continue const event = evt.type === "create" ? "add" : evt.type === "update" ? "change" : "unlink" - handleConfigChange(evt.path, event, configState) + handleConfigChange(evt.path, event, state) } } } - // Subscribe to a directory for config file changes async function subscribeConfigDir( w: typeof import("@parcel/watcher"), dir: string, backend: "windows" | "fs-events" | "inotify", - configState: ConfigWatcherState, + state: ConfigWatcherState, ): Promise { try { - const pending = w.subscribe(dir, createConfigCallback(dir, configState), { - // Ignore subdirectories - only watch root level files - // Note: callback also filters, this is defense in depth + const pending = w.subscribe(dir, createConfigCallback(dir, state), { ignore: ["*/**"], backend, }) @@ -131,9 +133,7 @@ export namespace FileWatcher { pending.then((s) => s.unsubscribe()).catch(() => {}) return undefined }) - if (sub) { - log.info("watching config directory", { dir }) - } + if (sub) log.info("watching config directory", { dir }) return sub } catch (error) { log.error("failed to watch config directory", { error, dir }) @@ -160,43 +160,38 @@ export namespace FileWatcher { if (!w) return {} const subs: ParcelWatcher.AsyncSubscription[] = [] - const cfgIgnores = cfg.watcher?.ignore ?? [] + const ignores = cfg.watcher?.ignore ?? [] - // Per-instance config watcher state const configState: ConfigWatcherState = { - disposeInFlight: false, - debounceTimer: undefined, + inFlight: false, + timer: undefined, } - // --- Config file watching (always enabled unless explicitly disabled) --- - if (!Flag.OPENCODE_DISABLE_CONFIG_WATCHER) { - // Watch project directory for config files + // Config file watching (experimental, opt-in) + if (Flag.OPENCODE_EXPERIMENTAL_CONFIG_WATCHER) { const projectSub = await subscribeConfigDir(w, Instance.directory, backend, configState) if (projectSub) subs.push(projectSub) - // Watch .opencode subdirectory if it exists const dotOpencode = path.join(Instance.directory, ".opencode") if (await Filesystem.exists(dotOpencode)) { - const dotSub = await subscribeConfigDir(w, dotOpencode, backend, configState) - if (dotSub) subs.push(dotSub) + const sub = await subscribeConfigDir(w, dotOpencode, backend, configState) + if (sub) subs.push(sub) } - // Watch ~/.opencode directory if it exists (user home) const homeOpencode = path.join(Global.Path.home, ".opencode") if (homeOpencode !== dotOpencode && (await Filesystem.exists(homeOpencode))) { - const homeSub = await subscribeConfigDir(w, homeOpencode, backend, configState) - if (homeSub) subs.push(homeSub) + const sub = await subscribeConfigDir(w, homeOpencode, backend, configState) + if (sub) subs.push(sub) } - // Watch global config directory (if different from project and home) const globalConfig = Global.Path.config if (globalConfig !== Instance.directory && globalConfig !== homeOpencode) { - const globalSub = await subscribeConfigDir(w, globalConfig, backend, configState) - if (globalSub) subs.push(globalSub) + const sub = await subscribeConfigDir(w, globalConfig, backend, configState) + if (sub) subs.push(sub) } } - // --- General file watching (experimental, git-only) --- + // General file watching (experimental) const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return for (const evt of evts) { @@ -208,7 +203,7 @@ export namespace FileWatcher { if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { const pending = w.subscribe(Instance.directory, subscribe, { - ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], + ignore: [...FileIgnore.PATTERNS, ...ignores], backend, }) const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { @@ -219,7 +214,7 @@ export namespace FileWatcher { if (sub) subs.push(sub) } - // --- Git HEAD watching (git-only) --- + // Git HEAD watching if (Instance.project.vcs === "git") { const vcsDir = await $`git rev-parse --git-dir` .quiet() @@ -228,13 +223,10 @@ export namespace FileWatcher { .text() .then((x) => path.resolve(Instance.worktree, x.trim())) .catch(() => undefined) - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const gitDirContents = await readdir(vcsDir).catch(() => []) - const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = w.subscribe(vcsDir, subscribe, { - ignore: ignoreList, - backend, - }) + if (vcsDir && !ignores.includes(".git") && !ignores.includes(vcsDir)) { + const contents = await readdir(vcsDir).catch(() => []) + const ignore = contents.filter((entry) => entry !== "HEAD") + const pending = w.subscribe(vcsDir, subscribe, { ignore, backend }) const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { log.error("failed to subscribe to vcsDir", { error: err }) pending.then((s) => s.unsubscribe()).catch(() => {}) @@ -247,10 +239,9 @@ export namespace FileWatcher { return { subs, configState } }, async (state) => { - // Clean up debounce timer - if (state.configState?.debounceTimer) { - clearTimeout(state.configState.debounceTimer) - state.configState.debounceTimer = undefined + if (state.configState?.timer) { + clearTimeout(state.configState.timer) + state.configState.timer = undefined } if (!state.subs) return await Promise.all(state.subs.map((sub) => sub?.unsubscribe())) @@ -258,9 +249,7 @@ export namespace FileWatcher { ) export function init() { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER && Flag.OPENCODE_DISABLE_CONFIG_WATCHER) { - return - } + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER && !Flag.OPENCODE_EXPERIMENTAL_CONFIG_WATCHER) return state() } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ffc98c1b2b4..eb99b31c3d0 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -33,7 +33,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") - export const OPENCODE_DISABLE_CONFIG_WATCHER = truthy("OPENCODE_DISABLE_CONFIG_WATCHER") + export const OPENCODE_EXPERIMENTAL_CONFIG_WATCHER = truthy("OPENCODE_EXPERIMENTAL_CONFIG_WATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") diff --git a/packages/opencode/test/file/config-watcher.test.ts b/packages/opencode/test/file/config-watcher.test.ts new file mode 100644 index 00000000000..ee4213e831c --- /dev/null +++ b/packages/opencode/test/file/config-watcher.test.ts @@ -0,0 +1,310 @@ +import { test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { FileWatcher } from "../../src/file/watcher" +import { Bus } from "../../src/bus" +import { MCP } from "../../src/mcp" +import { Config } from "../../src/config/config" +import path from "path" +import fs from "fs/promises" + +// Helper to sleep +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +test("config watcher detects opencode.json changes", async () => { + await using tmp = await tmpdir({ git: true }) + + let configChangedEvent: { file: string; event: string } | undefined + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Initialize the file watcher + FileWatcher.init() + + // Set up listener for config change events + const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + configChangedEvent = payload.properties + }) + + // Wait a bit for watcher to initialize + await sleep(500) + + // Write a new config file + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + cloudflare: { + type: "remote", + url: "https://docs.mcp.cloudflare.com/mcp", + }, + }, + }), + ) + + // Wait for the event (debounce is 500ms + some buffer) + await sleep(1000) + + unsub() + + // Verify the event was received + expect(configChangedEvent).toBeDefined() + expect(configChangedEvent?.file).toContain("opencode.json") + expect(["add", "change"]).toContain(configChangedEvent?.event ?? "") + }, + }) +}) + +test("config watcher triggers instance reload on config change", async () => { + await using tmp = await tmpdir({ git: true }) + + let disposeCount = 0 + const originalDispose = Instance.dispose + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Initialize file watcher + FileWatcher.init() + + // Wait for watcher to initialize + await sleep(500) + + // Track dispose calls - we need to do this after initialization + // to avoid counting the initial setup + + // Write initial config + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "test/model-1", + }), + ) + + // Wait for debounce + dispose (500ms debounce + buffer) + // The dispose will happen but we're inside Instance.provide so it's tricky to test + // Instead, let's verify the config change event fires + await sleep(1000) + }, + }) +}) + +test("config watcher watches .opencode directory", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create .opencode directory + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + }, + }) + + let configChangedEvent: { file: string; event: string } | undefined + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileWatcher.init() + + const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + configChangedEvent = payload.properties + }) + + await sleep(500) + + // Write config to .opencode directory + await Bun.write( + path.join(tmp.path, ".opencode", "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + test: { + type: "remote", + url: "https://example.com/mcp", + }, + }, + }), + ) + + await sleep(1000) + unsub() + + expect(configChangedEvent).toBeDefined() + expect(configChangedEvent?.file).toContain(".opencode") + expect(configChangedEvent?.file).toContain("opencode.json") + }, + }) +}) + +test("config watcher debounces rapid changes", async () => { + await using tmp = await tmpdir({ git: true }) + + const events: Array<{ file: string; event: string }> = [] + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileWatcher.init() + + const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + events.push(payload.properties) + }) + + await sleep(500) + + // Make rapid changes + for (let i = 0; i < 5; i++) { + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: `test/model-${i}`, + }), + ) + await sleep(50) // Small delay between writes + } + + // Wait for debounce to settle + await sleep(1000) + unsub() + + // Should have received events for each change + // (the debounce affects Instance.dispose, not the event publishing) + expect(events.length).toBeGreaterThanOrEqual(1) + }, + }) +}) + +test("MCP server config is loaded after adding to opencode.json", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Write config with MCP server + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + cloudflare: { + type: "remote", + url: "https://docs.mcp.cloudflare.com/mcp", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // MCP should be in config + const config = await Config.get() + expect(config.mcp?.cloudflare).toBeDefined() + // Just verify the MCP entry exists - the type is a discriminated union + expect(config.mcp?.cloudflare).toMatchObject({ + type: "remote", + url: "https://docs.mcp.cloudflare.com/mcp", + }) + }, + }) +}) + +test("config.json changes are also watched", async () => { + await using tmp = await tmpdir({ git: true }) + + let configChangedEvent: { file: string; event: string } | undefined + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileWatcher.init() + + const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + configChangedEvent = payload.properties + }) + + await sleep(500) + + // Write a config.json file (legacy config format) + await Bun.write( + path.join(tmp.path, "config.json"), + JSON.stringify({ + model: "test/model", + }), + ) + + await sleep(1000) + unsub() + + expect(configChangedEvent).toBeDefined() + expect(configChangedEvent?.file).toContain("config.json") + }, + }) +}) + +test("opencode.jsonc changes are watched", async () => { + await using tmp = await tmpdir({ git: true }) + + let configChangedEvent: { file: string; event: string } | undefined + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileWatcher.init() + + const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + configChangedEvent = payload.properties + }) + + await sleep(500) + + // Write a jsonc config file + await Bun.write( + path.join(tmp.path, "opencode.jsonc"), + `{ + // This is a comment + "$schema": "https://opencode.ai/config.json", + "model": "test/model" + }`, + ) + + await sleep(1000) + unsub() + + expect(configChangedEvent).toBeDefined() + expect(configChangedEvent?.file).toContain("opencode.jsonc") + }, + }) +}) + +test("non-config files in project root are not watched", async () => { + await using tmp = await tmpdir({ git: true }) + + let configChangedEvent: { file: string; event: string } | undefined + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + FileWatcher.init() + + const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + configChangedEvent = payload.properties + }) + + await sleep(500) + + // Write a non-config file + await Bun.write(path.join(tmp.path, "package.json"), JSON.stringify({ name: "test" })) + await Bun.write(path.join(tmp.path, "random.json"), JSON.stringify({ foo: "bar" })) + + await sleep(1000) + unsub() + + // Should NOT have received any config change events + expect(configChangedEvent).toBeUndefined() + }, + }) +}) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 35b0b6c7642..e73319f471d 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,9 @@ if (response.ok) { // Disable models.dev refresh to avoid race conditions during tests process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true" +// Enable experimental config watcher for tests +process.env["OPENCODE_EXPERIMENTAL_CONFIG_WATCHER"] = "true" + // Clear provider env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] From 96ca5ccbcd5e22ff5af27e06734063dde9679664 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Wed, 21 Jan 2026 17:39:27 -0500 Subject: [PATCH 3/3] refactor: move config reload to config watcher --- packages/opencode/src/config/watcher.ts | 226 ++++++++++++++++++ packages/opencode/src/file/watcher.ts | 216 ++++------------- packages/opencode/src/project/bootstrap.ts | 2 + .../opencode/test/file/config-watcher.test.ts | 29 ++- 4 files changed, 289 insertions(+), 184 deletions(-) create mode 100644 packages/opencode/src/config/watcher.ts diff --git a/packages/opencode/src/config/watcher.ts b/packages/opencode/src/config/watcher.ts new file mode 100644 index 00000000000..8c9368be0a2 --- /dev/null +++ b/packages/opencode/src/config/watcher.ts @@ -0,0 +1,226 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { GlobalBus } from "@/bus/global" +import z from "zod" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { Config } from "./config" +import { FileWatcher } from "../file/watcher" +import { Flag } from "../flag/flag" +import { Filesystem } from "../util/filesystem" +import { Global } from "../global" +import { Event as ServerEvent } from "../server/event" +import { lazy } from "../util/lazy" +import path from "path" +import type ParcelWatcher from "@parcel/watcher" + +const CONFIG_DEBOUNCE_MS = 500 +const CONFIG_FILES = new Set(["opencode.json", "opencode.jsonc", "config.json"]) + +type Scope = "global" | "local" + +interface DebounceState { + inFlight: boolean + timer: ReturnType | undefined +} + +interface WatchState { + subs: ParcelWatcher.AsyncSubscription[] + guard: DebounceState +} + +const log = Log.create({ service: "config.watcher" }) + +export namespace ConfigWatcher { + export const Event = { + Changed: BusEvent.define( + "config.watcher.changed", + z.object({ + file: z.string(), + event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), + scope: z.union([z.literal("global"), z.literal("local")]), + }), + ), + } + + const globalPaths = lazy(async () => { + const dirs = new Set() + dirs.add(Global.Path.config) + if (Flag.OPENCODE_CONFIG_DIR) dirs.add(Flag.OPENCODE_CONFIG_DIR) + + const home = await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: Global.Path.home, + stop: Global.Path.home, + }), + ) + for (const dir of home) dirs.add(dir) + + return { + dirs: Array.from(dirs), + file: Flag.OPENCODE_CONFIG, + } + }) + + const globalState = lazy(async (): Promise => { + const guard: DebounceState = { + inFlight: false, + timer: undefined, + } + const subs: ParcelWatcher.AsyncSubscription[] = [] + const global = await globalPaths() + for (const dir of global.dirs) { + if (!(await Filesystem.exists(dir))) continue + const sub = await watchDir(dir, "global", guard, matchesConfig, ["*/**"]) + if (sub) subs.push(sub) + } + + if (global.file) { + const sub = await watchDir(path.dirname(global.file), "global", guard, matchesFile(global.file), ["*/**"]) + if (sub) subs.push(sub) + } + + return { subs, guard } + }) + + const localState = Instance.state( + async (): Promise => { + const guard: DebounceState = { + inFlight: false, + timer: undefined, + } + const subs: ParcelWatcher.AsyncSubscription[] = [] + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + const global = await globalPaths() + const roots = parents(Instance.directory, Instance.worktree) + const opencode = await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: Instance.directory, + stop: Instance.worktree, + }), + ) + + const skip = new Set(global.dirs) + const match = global.file + ? (file: string, dir: string) => matchesConfig(file, dir) && file !== global.file + : matchesConfig + for (const dir of [...roots, ...opencode]) { + if (skip.has(dir)) continue + const sub = await watchDir(dir, "local", guard, match, ["*/**"]) + if (sub) subs.push(sub) + } + } + + return { subs, guard } + }, + async (state) => { + if (state.guard.timer) { + clearTimeout(state.guard.timer) + state.guard.timer = undefined + } + await Promise.all(state.subs.map((sub) => sub?.unsubscribe())) + }, + ) + + export function init() { + if (!Flag.OPENCODE_EXPERIMENTAL_CONFIG_WATCHER) return + globalState() + localState() + } +} + +function parents(start: string, stop: string): string[] { + if (start === stop) return [start] + const parent = path.dirname(start) + if (parent === start) return [start] + return [start, ...parents(parent, stop)] +} + +function matchesConfig(file: string, dir: string): boolean { + if (!isRoot(file, dir)) return false + return CONFIG_FILES.has(path.basename(file)) +} + +function matchesFile(target: string) { + return (file: string, _dir: string) => file === target +} + +function isRoot(file: string, dir: string): boolean { + const rel = path.relative(dir, file) + return rel.length > 0 && !rel.includes(path.sep) && !rel.startsWith("..") +} + +function watchDir( + dir: string, + scope: Scope, + guard: DebounceState, + match: (file: string, dir: string) => boolean, + ignore: string[], +) { + const callback: ParcelWatcher.SubscribeCallback = (err, evts) => { + if (err) return + for (const evt of evts) { + const event = toEvent(evt.type) + if (!event) continue + if (!match(evt.path, dir)) continue + handleChange(evt.path, event, scope, guard) + } + } + return FileWatcher.watch(dir, callback, ignore) +} + +function toEvent(type: string) { + if (type === "create") return "add" + if (type === "update") return "change" + if (type === "delete") return "unlink" + return undefined +} + +function handleChange(file: string, event: "add" | "change" | "unlink", scope: Scope, guard: DebounceState) { + log.info("config file changed", { file, event, scope }) + Bus.publish(ConfigWatcher.Event.Changed, { file, event, scope }) + + if (scope === "global") { + schedule(guard, reloadGlobal) + return + } + schedule(guard, reloadLocal) +} + +function schedule(state: DebounceState, fn: () => Promise) { + if (state.timer) clearTimeout(state.timer) + state.timer = setTimeout(async () => { + if (state.inFlight) { + log.debug("reload already in flight, skipping") + return + } + state.inFlight = true + try { + await fn() + } catch (error) { + log.error("failed to reload config", { error }) + } finally { + state.inFlight = false + } + }, CONFIG_DEBOUNCE_MS) +} + +async function reloadLocal() { + log.info("reloading instance due to config change", { scope: "local" }) + await Instance.dispose() +} + +async function reloadGlobal() { + log.info("reloading instances due to config change", { scope: "global" }) + Config.global.reset() + await Instance.disposeAll() + GlobalBus.emit("event", { + directory: "global", + payload: { + type: ServerEvent.Disposed.type, + properties: {}, + }, + }) +} diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 0674c0338ff..f384ce7e108 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,13 +1,10 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { GlobalBus } from "@/bus/global" import z from "zod" import { Instance } from "../project/instance" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" -import { Global } from "../global" -import { Filesystem } from "@/util/filesystem" import path from "path" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" @@ -20,14 +17,12 @@ import { readdir } from "fs/promises" import { createRequire } from "module" const SUBSCRIBE_TIMEOUT_MS = 10_000 -const CONFIG_DEBOUNCE_MS = 500 declare const OPENCODE_LIBC: string | undefined export namespace FileWatcher { const log = Log.create({ service: "file.watcher" }) - - const CONFIG_FILES = new Set(["opencode.json", "opencode.jsonc", "config.json"]) + const req = createRequire(import.meta.url) export const Event = { Updated: BusEvent.define( @@ -37,21 +32,11 @@ export namespace FileWatcher { event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), }), ), - ConfigChanged: BusEvent.define( - "file.watcher.config.changed", - z.object({ - file: z.string(), - event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), - }), - ), } - // Create require function that works in browser conditions - const require = createRequire(import.meta.url) - const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { - const binding = require( + const binding = req( `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, ) return createWrapper(binding) as typeof import("@parcel/watcher") @@ -61,137 +46,50 @@ export namespace FileWatcher { } }) - interface ConfigWatcherState { - inFlight: boolean - timer: ReturnType | undefined - } - - function handleConfigChange(file: string, event: "add" | "change" | "unlink", state: ConfigWatcherState) { - log.info("config file changed", { file, event }) - Bus.publish(Event.ConfigChanged, { file, event }) - - if (state.timer) clearTimeout(state.timer) - - state.timer = setTimeout(async () => { - if (state.inFlight) { - log.debug("dispose already in flight, skipping") - return - } - - state.inFlight = true - try { - Config.global.reset() - log.info("reloading instance due to config change") - await Instance.dispose() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: "global.disposed", - properties: {}, - }, - }) - } catch (error) { - log.error("failed to dispose instance", { error }) - } finally { - state.inFlight = false - } - }, CONFIG_DEBOUNCE_MS) - } - - function isRootLevel(file: string, dir: string): boolean { - const rel = path.relative(dir, file) - return rel.length > 0 && !rel.includes(path.sep) && !rel.startsWith("..") - } - - function createConfigCallback(dir: string, state: ConfigWatcherState): ParcelWatcher.SubscribeCallback { - return (err, evts) => { - if (err) return - for (const evt of evts) { - const filename = path.basename(evt.path) - if (!isRootLevel(evt.path, dir)) continue - if (!CONFIG_FILES.has(filename)) continue - - const event = evt.type === "create" ? "add" : evt.type === "update" ? "change" : "unlink" - handleConfigChange(evt.path, event, state) - } + const backend = lazy(() => { + const platform = process.platform + const mode = + platform === "win32" + ? "windows" + : platform === "darwin" + ? "fs-events" + : platform === "linux" + ? "inotify" + : undefined + if (!mode) { + log.error("watcher backend not supported", { platform }) + return } - } + log.info("watcher backend", { platform, backend: mode }) + return mode + }) - async function subscribeConfigDir( - w: typeof import("@parcel/watcher"), + export async function watch( dir: string, - backend: "windows" | "fs-events" | "inotify", - state: ConfigWatcherState, + callback: ParcelWatcher.SubscribeCallback, + ignore: string[] = [], ): Promise { - try { - const pending = w.subscribe(dir, createConfigCallback(dir, state), { - ignore: ["*/**"], - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to config directory", { error: err, dir }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) log.info("watching config directory", { dir }) - return sub - } catch (error) { - log.error("failed to watch config directory", { error, dir }) + const w = watcher() + if (!w) return + const mode = backend() + if (!mode) return + const pending = w.subscribe(dir, callback, { ignore, backend: mode }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe to directory", { error: err, dir }) + pending.then((s) => s.unsubscribe()).catch(() => {}) return undefined - } + }) + return sub } const state = Instance.state( async () => { + if (Instance.project.vcs !== "git") return {} log.info("init") const cfg = await Config.get() - const backend = (() => { - if (process.platform === "win32") return "windows" - if (process.platform === "darwin") return "fs-events" - if (process.platform === "linux") return "inotify" - })() - if (!backend) { - log.error("watcher backend not supported", { platform: process.platform }) - return {} - } - log.info("watcher backend", { platform: process.platform, backend }) - - const w = watcher() - if (!w) return {} - const subs: ParcelWatcher.AsyncSubscription[] = [] const ignores = cfg.watcher?.ignore ?? [] - const configState: ConfigWatcherState = { - inFlight: false, - timer: undefined, - } - - // Config file watching (experimental, opt-in) - if (Flag.OPENCODE_EXPERIMENTAL_CONFIG_WATCHER) { - const projectSub = await subscribeConfigDir(w, Instance.directory, backend, configState) - if (projectSub) subs.push(projectSub) - - const dotOpencode = path.join(Instance.directory, ".opencode") - if (await Filesystem.exists(dotOpencode)) { - const sub = await subscribeConfigDir(w, dotOpencode, backend, configState) - if (sub) subs.push(sub) - } - - const homeOpencode = path.join(Global.Path.home, ".opencode") - if (homeOpencode !== dotOpencode && (await Filesystem.exists(homeOpencode))) { - const sub = await subscribeConfigDir(w, homeOpencode, backend, configState) - if (sub) subs.push(sub) - } - - const globalConfig = Global.Path.config - if (globalConfig !== Instance.directory && globalConfig !== homeOpencode) { - const sub = await subscribeConfigDir(w, globalConfig, backend, configState) - if (sub) subs.push(sub) - } - } - - // General file watching (experimental) const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return for (const evt of evts) { @@ -202,54 +100,34 @@ export namespace FileWatcher { } if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - const pending = w.subscribe(Instance.directory, subscribe, { - ignore: [...FileIgnore.PATTERNS, ...ignores], - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to Instance.directory", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) + const sub = await watch(Instance.directory, subscribe, [...FileIgnore.PATTERNS, ...ignores]) if (sub) subs.push(sub) } - // Git HEAD watching - if (Instance.project.vcs === "git") { - const vcsDir = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => path.resolve(Instance.worktree, x.trim())) - .catch(() => undefined) - if (vcsDir && !ignores.includes(".git") && !ignores.includes(vcsDir)) { - const contents = await readdir(vcsDir).catch(() => []) - const ignore = contents.filter((entry) => entry !== "HEAD") - const pending = w.subscribe(vcsDir, subscribe, { ignore, backend }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to vcsDir", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) - } + const vcsDir = await $`git rev-parse --git-dir` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => path.resolve(Instance.worktree, x.trim())) + .catch(() => undefined) + if (vcsDir && !ignores.includes(".git") && !ignores.includes(vcsDir)) { + const contents = await readdir(vcsDir).catch(() => []) + const ignore = contents.filter((entry) => entry !== "HEAD") + const sub = await watch(vcsDir, subscribe, ignore) + if (sub) subs.push(sub) } - return { subs, configState } + return { subs } }, async (state) => { - if (state.configState?.timer) { - clearTimeout(state.configState.timer) - state.configState.timer = undefined - } if (!state.subs) return await Promise.all(state.subs.map((sub) => sub?.unsubscribe())) }, ) export function init() { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER && !Flag.OPENCODE_EXPERIMENTAL_CONFIG_WATCHER) return + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return state() } } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index efdcaba9909..76ad32824ee 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -3,6 +3,7 @@ import { Share } from "../share/share" import { Format } from "../format" import { LSP } from "../lsp" import { FileWatcher } from "../file/watcher" +import { ConfigWatcher } from "../config/watcher" import { File } from "../file" import { Project } from "./project" import { Bus } from "../bus" @@ -22,6 +23,7 @@ export async function InstanceBootstrap() { Format.init() await LSP.init() FileWatcher.init() + ConfigWatcher.init() File.init() Vcs.init() Snapshot.init() diff --git a/packages/opencode/test/file/config-watcher.test.ts b/packages/opencode/test/file/config-watcher.test.ts index ee4213e831c..c6a6447680a 100644 --- a/packages/opencode/test/file/config-watcher.test.ts +++ b/packages/opencode/test/file/config-watcher.test.ts @@ -1,9 +1,8 @@ import { test, expect } from "bun:test" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" -import { FileWatcher } from "../../src/file/watcher" +import { ConfigWatcher } from "../../src/config/watcher" import { Bus } from "../../src/bus" -import { MCP } from "../../src/mcp" import { Config } from "../../src/config/config" import path from "path" import fs from "fs/promises" @@ -20,10 +19,10 @@ test("config watcher detects opencode.json changes", async () => { directory: tmp.path, fn: async () => { // Initialize the file watcher - FileWatcher.init() + ConfigWatcher.init() // Set up listener for config change events - const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + const unsub = Bus.subscribe(ConfigWatcher.Event.Changed, (payload) => { configChangedEvent = payload.properties }) @@ -67,7 +66,7 @@ test("config watcher triggers instance reload on config change", async () => { directory: tmp.path, fn: async () => { // Initialize file watcher - FileWatcher.init() + ConfigWatcher.init() // Wait for watcher to initialize await sleep(500) @@ -106,9 +105,9 @@ test("config watcher watches .opencode directory", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileWatcher.init() + ConfigWatcher.init() - const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + const unsub = Bus.subscribe(ConfigWatcher.Event.Changed, (payload) => { configChangedEvent = payload.properties }) @@ -146,9 +145,9 @@ test("config watcher debounces rapid changes", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileWatcher.init() + ConfigWatcher.init() - const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + const unsub = Bus.subscribe(ConfigWatcher.Event.Changed, (payload) => { events.push(payload.properties) }) @@ -220,9 +219,9 @@ test("config.json changes are also watched", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileWatcher.init() + ConfigWatcher.init() - const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + const unsub = Bus.subscribe(ConfigWatcher.Event.Changed, (payload) => { configChangedEvent = payload.properties }) @@ -253,9 +252,9 @@ test("opencode.jsonc changes are watched", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileWatcher.init() + ConfigWatcher.init() - const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + const unsub = Bus.subscribe(ConfigWatcher.Event.Changed, (payload) => { configChangedEvent = payload.properties }) @@ -288,9 +287,9 @@ test("non-config files in project root are not watched", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileWatcher.init() + ConfigWatcher.init() - const unsub = Bus.subscribe(FileWatcher.Event.ConfigChanged, (payload) => { + const unsub = Bus.subscribe(ConfigWatcher.Event.Changed, (payload) => { configChangedEvent = payload.properties })