From 0b0b4af6e0e5d4e5567b867dc98930e1ed2882c3 Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Wed, 19 Aug 2026 14:24:15 -0700 Subject: [PATCH 1/4] feat: hot-reload skills, commands, agents and config on file change Behind OPENCODE_EXPERIMENTAL_HOT_RELOAD. The filesystem watcher gains subscriptions for config directories (global config dir and .opencode dirs) so changes there emit events even when the project watcher is off. A new HotReload service listens for those events, filters them to config-relevant paths, debounces, then reloads the instance through InstanceStore.reload and drops the v2 location-service layer, so both stacks reread disk state. Clients already re-sync on the existing server.instance.disposed event; the TUI additionally refreshes its v2 agent/command/skill stores. Plugin install artifacts (package.json, lockfiles) inside config dirs are ignored to avoid reload loops. Closes #8751 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTuWSW35XoRbPYixZr8oKQ --- packages/core/src/filesystem/watcher.ts | 19 ++- packages/core/src/flag/flag.ts | 3 + packages/core/test/filesystem/watcher.test.ts | 54 +++++++++ packages/opencode/src/config/hot-reload.ts | 114 ++++++++++++++++++ packages/opencode/src/effect/runtime-flags.ts | 1 + .../opencode/src/project/instance-store.ts | 13 +- .../opencode/test/config/hot-reload.test.ts | 35 ++++++ packages/tui/src/context/data.tsx | 11 ++ 8 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/config/hot-reload.ts create mode 100644 packages/opencode/test/config/hot-reload.test.ts diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index c5e20631917e..feece35c586c 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -103,15 +103,30 @@ const layer = Layer.effect( ) } - const config = (yield* (yield* Config.Service).entries()) + const entries = yield* (yield* Config.Service).entries() + const config = entries .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) - if (location.vcs && (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER)) { + const projectWatched = location.vcs && (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) + if (projectWatched) { yield* Effect.forkScoped( subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), ) } + // Hot reload wants change events for config directories (global config + // dir and .opencode dirs) even when the project itself is not watched. + if (yield* Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) { + for (const entry of entries) { + if (entry.type !== "directory") continue + const relative = path.relative(location.directory, entry.path) + const insideProject = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + if (projectWatched && insideProject) continue + if (!(yield* fs.isDir(entry.path))) continue + yield* Effect.forkScoped(subscribe(entry.path, [...Ignore.PATTERNS])) + } + } + if (location.vcs?.type === "git") { const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e2a..3cefe2744889 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -48,6 +48,9 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + OPENCODE_EXPERIMENTAL_HOT_RELOAD: Config.boolean("OPENCODE_EXPERIMENTAL_HOT_RELOAD").pipe( + Config.withDefault(false), + ), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 0a287ea010d9..4de8cf8a43f8 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -178,6 +178,60 @@ describeWatcher("Watcher", () => { ), ) + it.live("watches config directories when hot reload is enabled", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (item) => Effect.promise(() => item[Symbol.asyncDispose]()), + ) + const configDirectory = path.join(tmp.path, "config") + yield* Effect.promise(() => fs.mkdir(configDirectory, { recursive: true })) + + const entriesLayer = Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Directory({ type: "directory", path: AbsolutePath.make(configDirectory) }), + ]), + }), + ) + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) }, {})), + ) + const hotReloadFlags = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "false", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + OPENCODE_EXPERIMENTAL_HOT_RELOAD: "true", + }), + ) + + yield* Effect.gen(function* () { + const util = yield* FSUtil.Service + yield* ready(configDirectory) + const skill = path.join(configDirectory, "skill", "demo", "SKILL.md") + expect( + yield* nextUpdate( + (event) => event.file === skill && event.event === "add", + util.writeWithDirs(skill, "---\nname: demo\n---\nbody"), + ), + ).toEqual({ file: skill, event: "add" }) + // The project itself stays unwatched without the filewatcher flag. + const outside = path.join(tmp.path, "plain.txt") + yield* noUpdate((event) => event.file === outside, util.writeFileString(outside, "plain")) + }).pipe( + Effect.provide( + AppNodeBuilder.build(Watcher.node, [ + [Config.node, entriesLayer], + [Location.node, locationLayer], + ]).pipe(Layer.provide(hotReloadFlags)), + ), + ) + }), + ) + it.live("cleanup stops publishing events", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/opencode/src/config/hot-reload.ts b/packages/opencode/src/config/hot-reload.ts new file mode 100644 index 000000000000..783472f46de2 --- /dev/null +++ b/packages/opencode/src/config/hot-reload.ts @@ -0,0 +1,114 @@ +// Hot reload (experimental): when config-relevant files change on disk, +// reload the instance so skills, agents, commands and config pick up the +// change without restarting opencode. InstanceStore arms this after each +// boot and passes its own reload effect in, so clients get the existing +// server.instance.disposed event and re-sync. +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { Context, Effect, Layer } from "effect" +import { EventV2 } from "@opencode-ai/core/event" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Config } from "@/config/config" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Skill } from "@/skill" + +const DEBOUNCE_MS = 200 + +// Plugin installs write these into config directories on bootstrap; reacting +// to them would reload in a loop. +const IGNORED_FILES = new Set(["package.json", "bun.lock", "bun.lockb", "package-lock.json"]) + +export function relevant(file: string, roots: readonly string[]) { + const base = path.basename(file) + if (IGNORED_FILES.has(base)) return false + if (base === "opencode.json" || base === "opencode.jsonc") return true + return roots.some((root) => { + const relative = path.relative(root, file) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + }) +} + +export interface Interface { + readonly init: (reload: Effect.Effect) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HotReload") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const events = yield* EventV2Bridge.Service + const flags = yield* RuntimeFlags.Service + const locations = yield* LocationServiceMap.Service + const skill = yield* Skill.Service + const reloads = new Map>() + + const state = yield* InstanceState.make( + Effect.fn("HotReload.state")(function* (ctx) { + const value = { pending: false } + if (!flags.experimentalHotReload) return value + + const roots = [...(yield* config.directories()), ...(yield* skill.dirs())] + // The reload runs outside the listener fiber; capture this context so + // it still sees the instance services. + const runFork = Effect.runForkWith(yield* Effect.context()) + + const reload = Effect.gen(function* () { + yield* Effect.sleep(DEBOUNCE_MS) + yield* Effect.logInfo("hot reload", { directory: ctx.directory }) + // v2 location services cache config and skills per location; drop + // them so the rebuilt instance reads fresh state everywhere. + yield* locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })) + // InstanceStore.reload re-runs bootstrap, which re-arms this + // watcher with freshly discovered directories. + yield* reloads.get(ctx.directory) ?? Effect.void + }).pipe( + Effect.catchCause((cause) => Effect.logError("hot reload failed", { directory: ctx.directory, cause })), + ) + + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory) + return Effect.void + const data = event.data as EventV2.Data + if (!relevant(data.file, roots)) return Effect.void + return Effect.sync(() => { + if (value.pending) return + value.pending = true + runFork(reload) + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) + + return value + }), + ) + + return Service.of({ + init: Effect.fn("HotReload.init")(function* (reload) { + const ctx = yield* InstanceState.context + reloads.set(ctx.directory, reload) + yield* InstanceState.get(state) + }), + }) + }), +) + +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, EventV2Bridge.node, RuntimeFlags.node, Skill.node, locationServiceMapNode], +}) + +export * as HotReload from "./hot-reload" diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f076360..d80885476f57 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -48,6 +48,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + experimentalHotReload: bool("OPENCODE_EXPERIMENTAL_HOT_RELOAD"), experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549ddaff7..b07b8d415621 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" import { GlobalBus } from "@/bus/global" import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { HotReload } from "@/config/hot-reload" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance as runDisposers } from "@/effect/instance-registry" @@ -34,11 +35,13 @@ interface Entry { readonly deferred: Deferred.Deferred } -const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = + Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service const bootstrap = yield* InstanceBootstrap.Service + const hotReload = yield* HotReload.Service const scope = yield* Scope.Scope const cache = new Map() @@ -59,6 +62,12 @@ const layer: Layer.Layer Effect.logWarning("hot reload init failed", { cause })), + ) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) @@ -207,7 +216,7 @@ export const bootstrapNode = LayerNode.unbound(InstanceBootstrap.Service, Node.t export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [Project.node, bootstrapNode], + deps: [Project.node, bootstrapNode, HotReload.node], }) export * as InstanceStore from "./instance-store" 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 000000000000..9641658ab749 --- /dev/null +++ b/packages/opencode/test/config/hot-reload.test.ts @@ -0,0 +1,35 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { relevant } from "../../src/config/hot-reload" + +const config = path.resolve("/home/user/.config/opencode") +const project = path.resolve("/home/user/project/.opencode") +const roots = [config, project] + +describe("hot reload relevant", () => { + test("matches files inside config directories", () => { + expect(relevant(path.join(config, "skill", "demo", "SKILL.md"), roots)).toBe(true) + expect(relevant(path.join(project, "agent", "review.md"), roots)).toBe(true) + expect(relevant(path.join(project, "command", "deploy.md"), roots)).toBe(true) + }) + + test("matches opencode config files anywhere", () => { + expect(relevant(path.resolve("/home/user/project/opencode.json"), roots)).toBe(true) + expect(relevant(path.resolve("/home/user/project/opencode.jsonc"), roots)).toBe(true) + }) + + test("ignores files outside every root", () => { + expect(relevant(path.resolve("/home/user/project/src/index.ts"), roots)).toBe(false) + expect(relevant(path.resolve("/home/user/.config/other/file.md"), roots)).toBe(false) + }) + + test("ignores plugin install artifacts inside config directories", () => { + expect(relevant(path.join(config, "package.json"), roots)).toBe(false) + expect(relevant(path.join(config, "bun.lock"), roots)).toBe(false) + expect(relevant(path.join(project, "package-lock.json"), roots)).toBe(false) + }) + + test("does not treat sibling directories with a shared prefix as inside", () => { + expect(relevant(path.resolve("/home/user/project/.opencode-other/skill/SKILL.md"), roots)).toBe(false) + }) +}) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9b2e58907ad9..73a3ffa54056 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -122,6 +122,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function handleEvent(event: V2Event) { + // server.instance.disposed is a raw bus event outside the schema event + // union. Instance reloads (e.g. hot reload) dispose and rebuild server + // state, so refetch the location data that depends on it. + if ((event.type as string) === "server.instance.disposed") { + void Promise.allSettled([ + result.location.agent.refresh(), + result.location.command.refresh(), + result.location.skill.refresh(), + ]) + return + } switch (event.type) { case "catalog.updated": void Promise.all([ From 4e697c9cc99c86bae4ab9c76dc27d3854181930d Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Wed, 19 Aug 2026 14:26:08 -0700 Subject: [PATCH 2/4] docs: document OPENCODE_EXPERIMENTAL_HOT_RELOAD Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTuWSW35XoRbPYixZr8oKQ --- packages/web/src/content/docs/cli.mdx | 1 + packages/web/src/content/docs/skills.mdx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 94d9ba3c75d4..494980ec12a3 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -719,6 +719,7 @@ These environment variables enable experimental features that may change or be r | `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | number | Default timeout for bash commands in ms | | `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | Max output tokens for LLM responses | | `OPENCODE_EXPERIMENTAL_FILEWATCHER` | boolean | Enable file watcher for entire dir | +| `OPENCODE_EXPERIMENTAL_HOT_RELOAD` | boolean | Reload skills, agents, commands and config on file change | | `OPENCODE_EXPERIMENTAL_OXFMT` | boolean | Enable oxfmt formatter | | `OPENCODE_EXPERIMENTAL_LSP_TOOL` | boolean | Enable experimental LSP tool | | `OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER` | boolean | Disable file watcher | diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 2ce88ea5682f..e7c4a626b479 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -220,3 +220,5 @@ If a skill does not show up: 2. Check that frontmatter includes `name` and `description` 3. Ensure skill names are unique across all locations 4. Check permissions—skills with `deny` are hidden from agents + +Skills are discovered at startup. Restart opencode after adding or editing a skill, or set `OPENCODE_EXPERIMENTAL_HOT_RELOAD=true` to reload skills, agents, commands and config automatically when their files change. From 09bd5790cc4406d57823c45d2a8ad68ff963bcbd Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Wed, 19 Aug 2026 15:07:01 -0700 Subject: [PATCH 3/4] fix: address review findings in hot reload Persistent layer-level listener survives failed re-boots; whitelist relevance filter (config subdirs, skill dirs, exact config file paths) instead of blacklist, so plan saves and fixture opencode.json files no longer trigger reloads; reload forks are supervised by the layer scope; reload passes only the directory so project identity is re-derived and skips when the instance was disposed; location-service invalidation goes through a registry covering every built map and workspace-scoped refs; config-dir watches honor watcher.ignore; TUI refreshes all location data with the event location. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTuWSW35XoRbPYixZr8oKQ --- packages/core/src/filesystem/watcher.ts | 7 +- packages/core/src/location-services.ts | 22 ++- packages/opencode/src/config/hot-reload.ts | 159 ++++++++++-------- .../opencode/src/project/instance-store.ts | 15 +- .../opencode/test/config/hot-reload.test.ts | 38 +++-- packages/tui/src/context/data.tsx | 10 +- 6 files changed, 159 insertions(+), 92 deletions(-) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index feece35c586c..4a52d8bfde22 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -103,7 +103,8 @@ const layer = Layer.effect( ) } - const entries = yield* (yield* Config.Service).entries() + const configService = yield* Config.Service + const entries = yield* configService.entries() const config = entries .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) @@ -120,10 +121,10 @@ const layer = Layer.effect( for (const entry of entries) { if (entry.type !== "directory") continue const relative = path.relative(location.directory, entry.path) - const insideProject = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + const insideProject = !relative.startsWith("..") && !path.isAbsolute(relative) if (projectWatched && insideProject) continue if (!(yield* fs.isDir(entry.path))) continue - yield* Effect.forkScoped(subscribe(entry.path, [...Ignore.PATTERNS])) + yield* Effect.forkScoped(subscribe(entry.path, [...Ignore.PATTERNS, ...config])) } } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7da67673c319..b831d67ab41a 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -81,13 +81,33 @@ export const locationServices = LayerNode.group([ export type LocationServices = LayerNode.Output export type LocationError = LayerNode.Error +// Every built map registers here with the refs it has served, so instance +// reloads (hot reload, git init) can drop cached location layers across all +// maps and workspace-scoped refs, not just one they happen to hold. +const registry = new Set<{ map: LayerMap.LayerMap; refs: Set }>() + +export function invalidateLocationDirectory(directory: string) { + return Effect.forEach( + [...registry], + (entry) => + Effect.forEach( + [...entry.refs].filter((ref) => ref.directory === directory), + (ref) => entry.map.invalidate(ref).pipe(Effect.ignore), + { discard: true }, + ), + { discard: true }, + ) +} + export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { + const refs = new Set() return Layer.effect( LocationServiceMap.Service, LayerMap.make( (ref: Location.Ref) => { + refs.add(ref) const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) // Apply replacements during hoist, not afterward: replacements can // introduce new tagged dependencies (Location.boundNode depends on @@ -107,7 +127,7 @@ export function buildLocationServiceMap( ) }, { idleTimeToLive: "60 minutes" }, - ), + ).pipe(Effect.tap((map) => Effect.sync(() => registry.add({ map, refs })))), ) } diff --git a/packages/opencode/src/config/hot-reload.ts b/packages/opencode/src/config/hot-reload.ts index 783472f46de2..cb57347a1aa2 100644 --- a/packages/opencode/src/config/hot-reload.ts +++ b/packages/opencode/src/config/hot-reload.ts @@ -1,16 +1,18 @@ +export * as HotReload from "./hot-reload" + // Hot reload (experimental): when config-relevant files change on disk, // reload the instance so skills, agents, commands and config pick up the // change without restarting opencode. InstanceStore arms this after each // boot and passes its own reload effect in, so clients get the existing -// server.instance.disposed event and re-sync. +// server.instance.disposed event and re-sync. The listener lives at the +// layer, not in instance state, so a reload that fails to boot (for example +// an invalid config edit) stays armed and retries when the file is fixed. import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Scope } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Watcher } from "@opencode-ai/core/filesystem/watcher" -import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { invalidateLocationDirectory } from "@opencode-ai/core/location-services" import { Config } from "@/config/config" import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" @@ -19,96 +21,117 @@ import { Skill } from "@/skill" const DEBOUNCE_MS = 200 -// Plugin installs write these into config directories on bootstrap; reacting -// to them would reload in a loop. -const IGNORED_FILES = new Set(["package.json", "bun.lock", "bun.lockb", "package-lock.json"]) +// Config directories also hold runtime output (plans, plugin installs), so +// only these child directories are treated as config content. +const CONFIG_SEGMENTS = new Set([ + "agent", + "agents", + "command", + "commands", + "mode", + "modes", + "plugin", + "plugins", + "skill", + "skills", + "theme", + "themes", + "tool", + "tools", +]) + +export type Roots = { + configDirs: readonly string[] + skillDirs: readonly string[] + /** Exact config file paths, e.g. /opencode.json. */ + documents: ReadonlySet +} + +function inside(root: string, file: string) { + const relative = path.relative(root, file) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) +} -export function relevant(file: string, roots: readonly string[]) { - const base = path.basename(file) - if (IGNORED_FILES.has(base)) return false - if (base === "opencode.json" || base === "opencode.jsonc") return true - return roots.some((root) => { - const relative = path.relative(root, file) - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) +export function relevant(file: string, roots: Roots) { + if (roots.documents.has(file)) return true + if (roots.skillDirs.some((dir) => inside(dir, file))) return true + return roots.configDirs.some((dir) => { + if (!inside(dir, file)) return false + const segment = path.relative(dir, file).split(path.sep)[0] + return CONFIG_SEGMENTS.has(segment) }) } export interface Interface { - readonly init: (reload: Effect.Effect) => Effect.Effect + readonly init: (reload: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/HotReload") {} +type Entry = { + roots: Roots + reload: Effect.Effect +} + const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service - const locations = yield* LocationServiceMap.Service const skill = yield* Skill.Service - const reloads = new Map>() - - const state = yield* InstanceState.make( - Effect.fn("HotReload.state")(function* (ctx) { - const value = { pending: false } - if (!flags.experimentalHotReload) return value - - const roots = [...(yield* config.directories()), ...(yield* skill.dirs())] - // The reload runs outside the listener fiber; capture this context so - // it still sees the instance services. - const runFork = Effect.runForkWith(yield* Effect.context()) - - const reload = Effect.gen(function* () { - yield* Effect.sleep(DEBOUNCE_MS) - yield* Effect.logInfo("hot reload", { directory: ctx.directory }) - // v2 location services cache config and skills per location; drop - // them so the rebuilt instance reads fresh state everywhere. - yield* locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })) - // InstanceStore.reload re-runs bootstrap, which re-arms this - // watcher with freshly discovered directories. - yield* reloads.get(ctx.directory) ?? Effect.void - }).pipe( - Effect.catchCause((cause) => Effect.logError("hot reload failed", { directory: ctx.directory, cause })), - ) - - const unsubscribe = yield* events.listen((event) => { - if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory) - return Effect.void - const data = event.data as EventV2.Data - if (!relevant(data.file, roots)) return Effect.void - return Effect.sync(() => { - if (value.pending) return - value.pending = true - runFork(reload) - }) - }) - yield* Effect.addFinalizer(() => unsubscribe) + const scope = yield* Scope.Scope + const entries = new Map() + // Kept apart from entries: init replaces the entry on every boot, and a + // reload in flight must not lose its pending marker to that swap. + const pendings = new Set() - return value - }), - ) + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Watcher.Event.Updated.type) return Effect.void + const directory = event.location?.directory + const entry = directory === undefined ? undefined : entries.get(directory) + if (!directory || !entry || pendings.has(directory)) return Effect.void + const data = event.data as EventV2.Data + if (!relevant(data.file, entry.roots)) return Effect.void + pendings.add(directory) + return Effect.gen(function* () { + yield* Effect.sleep(DEBOUNCE_MS) + yield* Effect.logInfo("hot reload", { directory, file: data.file }) + // Drop cached v2 location layers so the rebuilt instance reads fresh + // state everywhere, then reload through InstanceStore. + yield* invalidateLocationDirectory(directory) + yield* entry.reload + }).pipe( + Effect.ensuring(Effect.sync(() => pendings.delete(directory))), + Effect.catchCause((cause) => Effect.logError("hot reload failed", { directory, cause })), + Effect.forkIn(scope), + Effect.asVoid, + ) + }) + yield* Effect.addFinalizer(() => unsubscribe) return Service.of({ init: Effect.fn("HotReload.init")(function* (reload) { + if (!flags.experimentalHotReload) return const ctx = yield* InstanceState.context - reloads.set(ctx.directory, reload) - yield* InstanceState.get(state) + const configDirs = yield* config.directories() + const documents = new Set( + [...configDirs, ctx.worktree, ctx.directory].flatMap((dir) => [ + path.join(dir, "opencode.json"), + path.join(dir, "opencode.jsonc"), + ]), + ) + entries.set(ctx.directory, { + roots: { configDirs, skillDirs: yield* skill.dirs(), documents }, + reload, + }) }), }) }), ) -const locationServiceMapNode = LayerNode.make({ - service: LocationServiceMap.Service, - layer: locationServiceMapLayer, - deps: [], -}) - export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Config.node, EventV2Bridge.node, RuntimeFlags.node, Skill.node, locationServiceMapNode], + deps: [Config.node, EventV2Bridge.node, RuntimeFlags.node, Skill.node], }) - -export * as HotReload from "./hot-reload" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index b07b8d415621..fdb32c9a9d7f 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -62,12 +62,15 @@ const layer: Layer.Layer Effect.logWarning("hot reload init failed", { cause })), - ) + // Pass only the directory so reload re-derives worktree and project, + // and skip when the instance was disposed while the reload waited. + const hotReloadRun = Effect.suspend(() => + cache.has(ctx.directory) ? reload({ directory: ctx.directory }).pipe(Effect.asVoid) : Effect.void, + ) + yield* hotReload.init(hotReloadRun).pipe( + Effect.provideService(InstanceRef, ctx), + Effect.catchCause((cause) => Effect.logWarning("hot reload init failed", { cause })), + ) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) diff --git a/packages/opencode/test/config/hot-reload.test.ts b/packages/opencode/test/config/hot-reload.test.ts index 9641658ab749..e55bf97f8457 100644 --- a/packages/opencode/test/config/hot-reload.test.ts +++ b/packages/opencode/test/config/hot-reload.test.ts @@ -4,29 +4,45 @@ import { relevant } from "../../src/config/hot-reload" const config = path.resolve("/home/user/.config/opencode") const project = path.resolve("/home/user/project/.opencode") -const roots = [config, project] +const skillDir = path.resolve("/home/user/.claude/skills/review") +const worktree = path.resolve("/home/user/project") +const roots = { + configDirs: [config, project], + skillDirs: [skillDir], + documents: new Set( + [config, project, worktree].flatMap((dir) => [path.join(dir, "opencode.json"), path.join(dir, "opencode.jsonc")]), + ), +} describe("hot reload relevant", () => { - test("matches files inside config directories", () => { + test("matches config content inside config directories", () => { expect(relevant(path.join(config, "skill", "demo", "SKILL.md"), roots)).toBe(true) expect(relevant(path.join(project, "agent", "review.md"), roots)).toBe(true) expect(relevant(path.join(project, "command", "deploy.md"), roots)).toBe(true) + expect(relevant(path.join(project, "plugin", "notify.ts"), roots)).toBe(true) }) - test("matches opencode config files anywhere", () => { - expect(relevant(path.resolve("/home/user/project/opencode.json"), roots)).toBe(true) - expect(relevant(path.resolve("/home/user/project/opencode.jsonc"), roots)).toBe(true) + test("matches files inside skill directories", () => { + expect(relevant(path.join(skillDir, "SKILL.md"), roots)).toBe(true) + expect(relevant(path.join(skillDir, "scripts", "run.py"), roots)).toBe(true) }) - test("ignores files outside every root", () => { - expect(relevant(path.resolve("/home/user/project/src/index.ts"), roots)).toBe(false) - expect(relevant(path.resolve("/home/user/.config/other/file.md"), roots)).toBe(false) + test("matches known config file paths only", () => { + expect(relevant(path.join(worktree, "opencode.json"), roots)).toBe(true) + expect(relevant(path.join(config, "opencode.jsonc"), roots)).toBe(true) + // A fixture opencode.json elsewhere in the tree is not config. + expect(relevant(path.join(worktree, "test", "fixtures", "opencode.json"), roots)).toBe(false) }) - test("ignores plugin install artifacts inside config directories", () => { + test("ignores runtime output inside config directories", () => { + expect(relevant(path.join(project, "plans", "2026-08-19-plan.md"), roots)).toBe(false) expect(relevant(path.join(config, "package.json"), roots)).toBe(false) - expect(relevant(path.join(config, "bun.lock"), roots)).toBe(false) - expect(relevant(path.join(project, "package-lock.json"), roots)).toBe(false) + expect(relevant(path.join(config, "node_modules", "pkg", "index.js"), roots)).toBe(false) + }) + + test("ignores files outside every root", () => { + expect(relevant(path.join(worktree, "src", "index.ts"), roots)).toBe(false) + expect(relevant(path.resolve("/home/user/.config/other/skill/SKILL.md"), roots)).toBe(false) }) test("does not treat sibling directories with a shared prefix as inside", () => { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 73a3ffa54056..e1a0f3b5919b 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -127,9 +127,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // state, so refetch the location data that depends on it. if ((event.type as string) === "server.instance.disposed") { void Promise.allSettled([ - result.location.agent.refresh(), - result.location.command.refresh(), - result.location.skill.refresh(), + result.location.agent.refresh(event.location), + result.location.command.refresh(event.location), + result.location.skill.refresh(event.location), + result.location.model.refresh(event.location), + result.location.provider.refresh(event.location), + result.location.integration.refresh(event.location), + result.location.reference.refresh(event.location), ]) return } From 7bf3b5378eeb95760c0ddbb88747b2c83550cf4f Mon Sep 17 00:00:00 2001 From: mccaffrey-jonathan Date: Thu, 20 Aug 2026 03:09:22 +0000 Subject: [PATCH 4/4] fix: stop hot reload dropping edits and leaking location maps - The debounce was a leading-edge lock held for the whole reload: the first event armed it and everything arriving during the 200ms wait *and* the instance rebuild was discarded with no re-check, so an edit made while a reload ran never loaded. Replaced with a trailing-edge debounce plus a dirty flag the driver re-checks after each reload. Extracted as schedule/settle so the sequence is unit tested. - location-services registered every LayerMap in module state and never released it, and the ref index grew forever even after LayerMap evicted the entry. Registration is now acquireRelease-scoped to the layer, and refs are indexed by directory and dropped on invalidate. Also removes cross-test contamination: every test that built a map left a live entry behind. - Treat opencode.json[c] at every level between the instance directory and the worktree root as config - ConfigPaths.files loads all of them - and honour OPENCODE_CONFIG when it points inside a watched config directory. - Carry the project through the hot-reload reload so the emitted server.instance.disposed event is stamped like every other disposal. - Log refresh failures in the TUI instead of swallowing them; a silent rejection leaves exactly the stale skill list hot reload exists to replace. - Document that a config directory must exist at startup to be watched, and that a reload interrupts an in-flight turn. --- packages/core/src/location-services.ts | 44 ++++++-- packages/opencode/src/config/hot-reload.ts | 100 ++++++++++++++---- .../opencode/src/project/instance-store.ts | 15 ++- .../opencode/test/config/hot-reload.test.ts | 53 +++++++++- packages/tui/src/context/data.tsx | 7 +- packages/web/src/content/docs/skills.mdx | 2 + 6 files changed, 182 insertions(+), 39 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index b831d67ab41a..17b72dfef7f1 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -83,18 +83,27 @@ export type LocationError = LayerNode.Error // Every built map registers here with the refs it has served, so instance // reloads (hot reload, git init) can drop cached location layers across all -// maps and workspace-scoped refs, not just one they happen to hold. -const registry = new Set<{ map: LayerMap.LayerMap; refs: Set }>() +// maps and workspace-scoped refs, not just one they happen to hold. Entries are +// released with the layer scope that created them, so a torn-down map - a +// finished test, a closed workspace - does not stay reachable from module state. +type RegistryEntry = { + map: LayerMap.LayerMap + refs: Map> +} + +const registry = new Set() export function invalidateLocationDirectory(directory: string) { return Effect.forEach( [...registry], - (entry) => - Effect.forEach( - [...entry.refs].filter((ref) => ref.directory === directory), - (ref) => entry.map.invalidate(ref).pipe(Effect.ignore), - { discard: true }, - ), + (entry) => { + const refs = entry.refs.get(directory) + if (!refs) return Effect.void + // The cached layers are gone once invalidated; the map re-registers each ref + // when it next serves it, so dropping them here keeps the index bounded. + entry.refs.delete(directory) + return Effect.forEach([...refs], (ref) => entry.map.invalidate(ref).pipe(Effect.ignore), { discard: true }) + }, { discard: true }, ) } @@ -102,12 +111,14 @@ export function invalidateLocationDirectory(directory: string) { export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { - const refs = new Set() + const refs = new Map>() return Layer.effect( LocationServiceMap.Service, LayerMap.make( (ref: Location.Ref) => { - refs.add(ref) + const served = refs.get(ref.directory) ?? new Set() + served.add(ref) + refs.set(ref.directory, served) const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) // Apply replacements during hoist, not afterward: replacements can // introduce new tagged dependencies (Location.boundNode depends on @@ -127,7 +138,18 @@ export function buildLocationServiceMap( ) }, { idleTimeToLive: "60 minutes" }, - ).pipe(Effect.tap((map) => Effect.sync(() => registry.add({ map, refs })))), + ).pipe( + Effect.tap((map) => + Effect.acquireRelease( + Effect.sync(() => { + const entry: RegistryEntry = { map, refs } + registry.add(entry) + return entry + }), + (entry) => Effect.sync(() => registry.delete(entry)), + ), + ), + ), ) } diff --git a/packages/opencode/src/config/hot-reload.ts b/packages/opencode/src/config/hot-reload.ts index cb57347a1aa2..9c11607a0c04 100644 --- a/packages/opencode/src/config/hot-reload.ts +++ b/packages/opencode/src/config/hot-reload.ts @@ -11,6 +11,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { EventV2 } from "@opencode-ai/core/event" +import { Flag } from "@opencode-ai/core/flag/flag" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { invalidateLocationDirectory } from "@opencode-ai/core/location-services" import { Config } from "@/config/config" @@ -19,7 +20,7 @@ import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { Skill } from "@/skill" -const DEBOUNCE_MS = 200 +export const DEBOUNCE_MS = 200 // Config directories also hold runtime output (plans, plugin installs), so // only these child directories are treated as config content. @@ -73,6 +74,51 @@ type Entry = { reload: Effect.Effect } +export type Pending = { + /** Epoch ms the reload fires at; every further event pushes it out. */ + deadline: number + /** Most recent relevant file, for the log line. */ + file: string + running: boolean + dirty: boolean +} + +/** + * Trailing edge. An edit that lands while the timer counts down pushes the deadline + * out; one that lands while a reload is in flight marks the entry dirty so the driver + * loops again. Dropping either would leave the file that triggered the reload + * unloaded until some later, unrelated edit. + * + * Returns the state to drive when this call created it, undefined when a driver for + * the directory is already running. + */ +export function schedule(pendings: Map, directory: string, file: string, now: number) { + const existing = pendings.get(directory) + if (existing) { + existing.deadline = now + DEBOUNCE_MS + existing.file = file + if (existing.running) existing.dirty = true + return undefined + } + const state: Pending = { deadline: now + DEBOUNCE_MS, file, running: false, dirty: false } + pendings.set(directory, state) + return state +} + +/** + * Called once a reload finishes. Returns true when the driver may stop, false when an + * edit landed mid-reload and the loop has to run again. Stays synchronous so no event + * can slip in between observing dirty and dropping the entry. + */ +export function settle(pendings: Map, directory: string) { + const state = pendings.get(directory) + if (!state) return true + state.running = false + if (state.dirty) return false + pendings.delete(directory) + return true +} + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -83,30 +129,38 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const entries = new Map() // Kept apart from entries: init replaces the entry on every boot, and a - // reload in flight must not lose its pending marker to that swap. - const pendings = new Set() + // reload in flight must not lose its pending state to that swap. + const pendings = new Map() const unsubscribe = yield* events.listen((event) => { if (event.type !== Watcher.Event.Updated.type) return Effect.void const directory = event.location?.directory const entry = directory === undefined ? undefined : entries.get(directory) - if (!directory || !entry || pendings.has(directory)) return Effect.void + if (!directory || !entry) return Effect.void const data = event.data as EventV2.Data if (!relevant(data.file, entry.roots)) return Effect.void - pendings.add(directory) + + const state = schedule(pendings, directory, data.file, Date.now()) + if (!state) return Effect.void + return Effect.gen(function* () { - yield* Effect.sleep(DEBOUNCE_MS) - yield* Effect.logInfo("hot reload", { directory, file: data.file }) - // Drop cached v2 location layers so the rebuilt instance reads fresh - // state everywhere, then reload through InstanceStore. - yield* invalidateLocationDirectory(directory) - yield* entry.reload - }).pipe( - Effect.ensuring(Effect.sync(() => pendings.delete(directory))), - Effect.catchCause((cause) => Effect.logError("hot reload failed", { directory, cause })), - Effect.forkIn(scope), - Effect.asVoid, - ) + while (true) { + for (let wait = state.deadline - Date.now(); wait > 0; wait = state.deadline - Date.now()) { + yield* Effect.sleep(wait) + } + state.running = true + state.dirty = false + yield* Effect.logInfo("hot reload", { directory, file: state.file }) + // Drop cached v2 location layers so the rebuilt instance reads fresh + // state everywhere, then reload through InstanceStore. Re-read the entry: + // init replaces it on every boot. + yield* invalidateLocationDirectory(directory).pipe( + Effect.andThen(entries.get(directory)?.reload ?? Effect.void), + Effect.catchCause((cause) => Effect.logError("hot reload failed", { directory, cause })), + ) + if (settle(pendings, directory)) return + } + }).pipe(Effect.forkIn(scope), Effect.asVoid) }) yield* Effect.addFinalizer(() => unsubscribe) @@ -115,12 +169,16 @@ const layer = Layer.effect( if (!flags.experimentalHotReload) return const ctx = yield* InstanceState.context const configDirs = yield* config.directories() + // ConfigPaths.files walks opencode.json[c] from the instance directory up to + // the worktree root, so every level in between is config, not just the ends. + const documentDirs = new Set([...configDirs, ctx.worktree, ctx.directory]) + for (let dir = ctx.directory; inside(ctx.worktree, dir); dir = path.dirname(dir)) documentDirs.add(dir) const documents = new Set( - [...configDirs, ctx.worktree, ctx.directory].flatMap((dir) => [ - path.join(dir, "opencode.json"), - path.join(dir, "opencode.jsonc"), - ]), + [...documentDirs].flatMap((dir) => [path.join(dir, "opencode.json"), path.join(dir, "opencode.jsonc")]), ) + // Only fires when the explicit config file happens to sit inside a watched + // config directory; one outside them still gets no events. + if (Flag.OPENCODE_CONFIG) documents.add(path.resolve(Flag.OPENCODE_CONFIG)) entries.set(ctx.directory, { roots: { configDirs, skillDirs: yield* skill.dirs(), documents }, reload, diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index fdb32c9a9d7f..6b05600e47dd 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -35,8 +35,9 @@ interface Entry { readonly deferred: Deferred.Deferred } -const layer: Layer.Layer = - Layer.effect( +type LayerDeps = Project.Service | InstanceBootstrap.Service | HotReload.Service + +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service @@ -62,10 +63,14 @@ const layer: Layer.Layer - cache.has(ctx.directory) ? reload({ directory: ctx.directory }).pipe(Effect.asVoid) : Effect.void, + cache.has(ctx.directory) + ? reload({ directory: ctx.directory, project: ctx.project }).pipe(Effect.asVoid) + : Effect.void, ) yield* hotReload.init(hotReloadRun).pipe( Effect.provideService(InstanceRef, ctx), diff --git a/packages/opencode/test/config/hot-reload.test.ts b/packages/opencode/test/config/hot-reload.test.ts index e55bf97f8457..a9469d9367e5 100644 --- a/packages/opencode/test/config/hot-reload.test.ts +++ b/packages/opencode/test/config/hot-reload.test.ts @@ -1,6 +1,6 @@ import path from "path" import { describe, expect, test } from "bun:test" -import { relevant } from "../../src/config/hot-reload" +import { DEBOUNCE_MS, relevant, schedule, settle, type Pending } from "../../src/config/hot-reload" const config = path.resolve("/home/user/.config/opencode") const project = path.resolve("/home/user/project/.opencode") @@ -49,3 +49,54 @@ describe("hot reload relevant", () => { expect(relevant(path.resolve("/home/user/project/.opencode-other/skill/SKILL.md"), roots)).toBe(false) }) }) + +describe("hot reload debounce", () => { + const dir = path.resolve("/home/user/project") + + test("the first event starts a driver and later ones only push the deadline out", () => { + const pendings = new Map() + const state = schedule(pendings, dir, "a.md", 1_000) + expect(state).toBeDefined() + expect(state!.deadline).toBe(1_000 + DEBOUNCE_MS) + + // A second driver would reload twice for one burst of editor writes. + expect(schedule(pendings, dir, "b.md", 1_100)).toBeUndefined() + expect(state!.deadline).toBe(1_100 + DEBOUNCE_MS) + expect(state!.file).toBe("b.md") + expect(state!.dirty).toBe(false) + }) + + test("an edit during the reload keeps the driver looping", () => { + const pendings = new Map() + const state = schedule(pendings, dir, "a.md", 1_000)! + state.running = true + + schedule(pendings, dir, "b.md", 1_500) + expect(state.dirty).toBe(true) + + // Without this the edit at 1_500 would never load: the old code held a single + // pending marker across the whole reload and dropped everything that arrived. + expect(settle(pendings, dir)).toBe(false) + expect(pendings.has(dir)).toBe(true) + expect(state.running).toBe(false) + expect(state.deadline).toBe(1_500 + DEBOUNCE_MS) + }) + + test("a quiet reload drops the entry so the next edit starts fresh", () => { + const pendings = new Map() + const state = schedule(pendings, dir, "a.md", 1_000)! + state.running = true + + expect(settle(pendings, dir)).toBe(true) + expect(pendings.has(dir)).toBe(false) + expect(schedule(pendings, dir, "c.md", 2_000)).toBeDefined() + }) + + test("directories debounce independently", () => { + const pendings = new Map() + const other = path.resolve("/home/user/other") + expect(schedule(pendings, dir, "a.md", 1_000)).toBeDefined() + expect(schedule(pendings, other, "a.md", 1_000)).toBeDefined() + expect(pendings.size).toBe(2) + }) +}) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index e1a0f3b5919b..2c3fd33f7479 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -134,7 +134,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.refresh(event.location), result.location.integration.refresh(event.location), result.location.reference.refresh(event.location), - ]) + ]).then((settled) => { + // These race the instance rebuild. Swallowing a rejection here leaves the + // TUI showing the stale skills and commands hot reload exists to replace. + for (const failure of settled.filter((item) => item.status === "rejected")) + console.error("Failed to refresh location data after instance reload", failure.reason) + }) return } switch (event.type) { diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index e7c4a626b479..2bdedfeb126a 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -222,3 +222,5 @@ If a skill does not show up: 4. Check permissions—skills with `deny` are hidden from agents Skills are discovered at startup. Restart opencode after adding or editing a skill, or set `OPENCODE_EXPERIMENTAL_HOT_RELOAD=true` to reload skills, agents, commands and config automatically when their files change. + +Hot reload watches config directories that already exist when opencode starts, so creating a project's first `.opencode` directory still needs a restart. A reload rebuilds the instance, which interrupts a turn that is in flight.