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
42 changes: 42 additions & 0 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,44 @@ 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. 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<Location.Ref, LocationServices, LocationError>
refs: Map<string, Set<Location.Ref>>
}

const registry = new Set<RegistryEntry>()

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<LocationServiceMap.Service> {
const refs = new Map<string, Set<Location.Ref>>()
return Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const served = refs.get(ref.directory) ?? new Set<Location.Ref>()
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
Expand All @@ -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)),
),
),
),
)
}
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
195 changes: 195 additions & 0 deletions packages/opencode/src/config/hot-reload.ts
Original file line number Diff line number Diff line change
@@ -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. <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>
}

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

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<typeof Watcher.Event.Updated>
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<string>([...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],
})
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
Loading
Loading