Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/core/src/filesystem/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,33 @@ export const locationServices = LayerNode.group([
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>

// 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<Location.Ref, LocationServices, LocationError>; refs: Set<Location.Ref> }>()

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<LocationServiceMap.Service> {
const refs = new Set<Location.Ref>()
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
Expand All @@ -107,7 +127,7 @@ export function buildLocationServiceMap(
)
},
{ idleTimeToLive: "60 minutes" },
),
).pipe(Effect.tap((map) => Effect.sync(() => registry.add({ map, refs })))),
)
}

Expand Down
54 changes: 54 additions & 0 deletions packages/core/test/filesystem/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions packages/opencode/src/config/hot-reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 { 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"

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. <worktree>/opencode.json. */
documents: ReadonlySet<string>
}

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<void>) => Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/HotReload") {}

type Entry = {
roots: Roots
reload: Effect.Effect<void>
}

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<string, Entry>()
// 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<string>()

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<typeof Watcher.Event.Updated>
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
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,
})
}),
})
}),
)

export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [Config.node, EventV2Bridge.node, RuntimeFlags.node, Skill.node],
})
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class Service extends ConfigService.Service<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"),
Expand Down
16 changes: 14 additions & 2 deletions packages/opencode/src/project/instance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -34,11 +35,13 @@ interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext>
}

const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service | HotReload.Service> =
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<string, Entry>()

Expand All @@ -59,6 +62,15 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
})),
)
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
// 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"))

Expand Down Expand Up @@ -207,7 +219,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"
51 changes: 51 additions & 0 deletions packages/opencode/test/config/hot-reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 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)
})
})
Loading
Loading