diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index c5e20631917e..4a52d8bfde22 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -103,15 +103,31 @@ const layer = Layer.effect( ) } - const config = (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 ?? []) - 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.startsWith("..") && !path.isAbsolute(relative) + if (projectWatched && insideProject) continue + if (!(yield* fs.isDir(entry.path))) continue + yield* Effect.forkScoped(subscribe(entry.path, [...Ignore.PATTERNS, ...config])) + } + } + 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/src/location-services.ts b/packages/core/src/location-services.ts index 7da67673c319..17b72dfef7f1 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -81,13 +81,44 @@ 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. 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) => { + 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 }, + ) +} + export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { + const refs = new Map>() return Layer.effect( LocationServiceMap.Service, LayerMap.make( (ref: Location.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 @@ -107,6 +138,17 @@ export function buildLocationServiceMap( ) }, { idleTimeToLive: "60 minutes" }, + ).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/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..9c11607a0c04 --- /dev/null +++ b/packages/opencode/src/config/hot-reload.ts @@ -0,0 +1,195 @@ +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. 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, 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" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Skill } from "@/skill" + +export const DEBOUNCE_MS = 200 + +// 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: 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 +} + +export class Service extends Context.Service()("@opencode/HotReload") {} + +type Entry = { + roots: Roots + 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* () { + const config = yield* Config.Service + const events = yield* EventV2Bridge.Service + const flags = yield* RuntimeFlags.Service + const skill = yield* Skill.Service + 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 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) return Effect.void + const data = event.data as EventV2.Data + if (!relevant(data.file, entry.roots)) return Effect.void + + const state = schedule(pendings, directory, data.file, Date.now()) + if (!state) return Effect.void + + return Effect.gen(function* () { + 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) + + return Service.of({ + init: Effect.fn("HotReload.init")(function* (reload) { + 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( + [...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, + }) + }), + }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, EventV2Bridge.node, RuntimeFlags.node, Skill.node], +}) 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..6b05600e47dd 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,14 @@ 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 const bootstrap = yield* InstanceBootstrap.Service + const hotReload = yield* HotReload.Service const scope = yield* Scope.Scope const cache = new Map() @@ -59,6 +63,19 @@ const layer: Layer.Layer + 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), + Effect.catchCause((cause) => Effect.logWarning("hot reload init failed", { cause })), + ) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) @@ -207,7 +224,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..a9469d9367e5 --- /dev/null +++ b/packages/opencode/test/config/hot-reload.test.ts @@ -0,0 +1,102 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +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") +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 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 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("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 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, "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", () => { + 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 9b2e58907ad9..2c3fd33f7479 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -122,6 +122,26 @@ 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(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), + ]).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) { case "catalog.updated": void Promise.all([ 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..2bdedfeb126a 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -220,3 +220,7 @@ 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. + +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.