From b41429178f1cb831f10a641faab6f6d404c59235 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:24:40 +0000 Subject: [PATCH 01/15] feat(opencode): add conservative workspace hot reload # Conflicts: # packages/opencode/src/flag/flag.ts --- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/flag/flag.ts | 3 + packages/opencode/src/project/bootstrap.ts | 2 + packages/opencode/src/project/hotreload.ts | 169 ++++++++++++++++++ .../opencode/test/project/hotreload.test.ts | 37 ++++ 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/project/hotreload.ts create mode 100644 packages/opencode/test/project/hotreload.test.ts diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index c4a4747777e2..7a97f0a49e51 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -46,7 +46,7 @@ export namespace FileWatcher { const state = Instance.state( async () => { - if (Instance.project.vcs !== "git") return {} + if (Instance.project.vcs !== "git" && !Flag.OPENCODE_HOT_RELOAD) return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -75,7 +75,7 @@ export namespace FileWatcher { const subs: ParcelWatcher.AsyncSubscription[] = [] const cfgIgnores = cfg.watcher?.ignore ?? [] - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_HOT_RELOAD) { const pending = w.subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], backend, diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0049d716d095..f8a3d7df8871 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -31,6 +31,9 @@ export namespace Flag { export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") + export const OPENCODE_HOT_RELOAD = truthy("OPENCODE_HOT_RELOAD") + export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS") + export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS") // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f853..3ddcadec9786 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { HotReload } from "./hotreload" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -20,6 +21,7 @@ export async function InstanceBootstrap() { Format.init() await LSP.init() FileWatcher.init() + HotReload.init() File.init() Vcs.init() Snapshot.init() diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts new file mode 100644 index 000000000000..6ed677382d2b --- /dev/null +++ b/packages/opencode/src/project/hotreload.ts @@ -0,0 +1,169 @@ +import path from "path" +import { Bus } from "@/bus" +import { FileWatcher } from "@/file/watcher" +import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" +import { Instance } from "./instance" + +export namespace HotReload { + const log = Log.create({ service: "project.hotreload" }) + + const watched = new Set([ + "agent", + "agents", + "command", + "commands", + "mode", + "modes", + "plugin", + "plugins", + "skill", + "skills", + "tool", + "tools", + ]) + + function normalize(file: string) { + return file.split(path.sep).join("/") + } + + function temp(file: string) { + const base = file.split("/").at(-1) ?? file + if (!base) return true + if (base === ".DS_Store" || base === "Thumbs.db") return true + if (base.startsWith(".#")) return true + if (base.endsWith("~")) return true + if (base.endsWith(".tmp")) return true + if (base.endsWith(".swp")) return true + if (base.endsWith(".swo")) return true + if (base.endsWith(".swx")) return true + if (base.endsWith(".bak")) return true + if (base.endsWith(".orig")) return true + if (base.endsWith(".rej")) return true + if (base.endsWith(".crdownload")) return true + return false + } + + function rel(root: string, file: string) { + const roots = new Set([normalize(root).replace(/\/+$/, "")]) + const files = new Set([normalize(file)]) + + if (process.platform === "darwin") { + for (const item of [...roots]) { + if (item.startsWith("/private/")) roots.add(item.slice("/private".length)) + if (item.startsWith("/var/")) roots.add(`/private${item}`) + } + for (const item of [...files]) { + if (item.startsWith("/private/")) files.add(item.slice("/private".length)) + if (item.startsWith("/var/")) files.add(`/private${item}`) + } + } + + for (const rootItem of roots) { + for (const fileItem of files) { + if (fileItem.includes("/.git/")) continue + if (fileItem === rootItem) continue + if (!fileItem.startsWith(`${rootItem}/`)) continue + return fileItem.slice(rootItem.length + 1) + } + } + } + + export function classify(root: string, file: string) { + const relFile = rel(root, file) + if (!relFile) return + if (temp(relFile)) return + if (relFile === "opencode.json") return relFile + if (relFile === "opencode.jsonc") return relFile + if (relFile === "AGENTS.md") return relFile + if (relFile === ".opencode/opencode.json") return relFile + if (relFile === ".opencode/opencode.jsonc") return relFile + if (!relFile.startsWith(".opencode/")) return + if (relFile.startsWith(".opencode/openwork/")) return + + const parts = relFile.split("/") + if (parts.length < 3) return + if (!watched.has(parts[1])) return + + const base = parts.at(-1) ?? "" + if (!base.includes(".")) return + return relFile + } + + const state = Instance.state( + () => { + if (!Flag.OPENCODE_HOT_RELOAD) return {} + + const debounce = Flag.OPENCODE_HOT_RELOAD_DEBOUNCE_MS ?? 700 + const cooldown = Flag.OPENCODE_HOT_RELOAD_COOLDOWN_MS ?? 1500 + let timer: ReturnType | undefined + let busy = false + let last = 0 + let latest: + | { + file: string + event: "add" | "change" | "unlink" + } + | undefined + + const flush = () => { + timer = undefined + if (busy) return + + const now = Date.now() + const wait = cooldown - (now - last) + if (wait > 0) { + timer = setTimeout(flush, wait) + return + } + + const hit = latest + if (!hit) return + + busy = true + last = now + log.info("hot reload triggered", { file: hit.file, event: hit.event }) + void Instance.dispose() + .catch((error) => { + log.error("hot reload failed", { error, file: hit.file, event: hit.event }) + }) + .finally(() => { + busy = false + }) + } + + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(flush, debounce) + } + + const unsub = Bus.subscribe(FileWatcher.Event.Updated, (event) => { + const rel = classify(Instance.directory, event.properties.file) + if (!rel) return + latest = { + file: rel, + event: event.properties.event, + } + schedule() + }) + + log.info("hot reload enabled", { debounce, cooldown }) + return { + unsub, + clear() { + if (!timer) return + clearTimeout(timer) + timer = undefined + }, + } + }, + async (entry) => { + entry.unsub?.() + entry.clear?.() + }, + ) + + export function init() { + state() + } +} diff --git a/packages/opencode/test/project/hotreload.test.ts b/packages/opencode/test/project/hotreload.test.ts new file mode 100644 index 000000000000..984e352209a8 --- /dev/null +++ b/packages/opencode/test/project/hotreload.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import { HotReload } from "../../src/project/hotreload" + +const root = "/tmp/openwork-hotreload" + +test("matches project config files", () => { + expect(HotReload.classify(root, `${root}/opencode.json`)).toBe("opencode.json") + expect(HotReload.classify(root, `${root}/opencode.jsonc`)).toBe("opencode.jsonc") + expect(HotReload.classify(root, `${root}/AGENTS.md`)).toBe("AGENTS.md") +}) + +test("matches opencode directories", () => { + expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md`)).toBe( + ".opencode/skills/new-skill/SKILL.md", + ) + expect(HotReload.classify(root, `${root}/.opencode/commands/fix.md`)).toBe( + ".opencode/commands/fix.md", + ) + expect(HotReload.classify(root, `${root}/.opencode/plugins/example.ts`)).toBe( + ".opencode/plugins/example.ts", + ) +}) + +test("ignores metadata, temp files, and unrelated files", () => { + expect(HotReload.classify(root, `${root}/README.md`)).toBeUndefined() + expect(HotReload.classify(root, `${root}/.opencode/openwork/openwork.json`)).toBeUndefined() + expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md.swp`)).toBeUndefined() + expect(HotReload.classify(root, `${root}/.git/HEAD`)).toBeUndefined() + expect(HotReload.classify(root, `/tmp/other/opencode.json`)).toBeUndefined() +}) + +test("matches darwin /private path aliases", () => { + const privateRoot = "/private/tmp/openwork-hotreload" + expect(HotReload.classify(privateRoot, "/tmp/openwork-hotreload/.opencode/commands/fix.md")).toBe( + ".opencode/commands/fix.md", + ) +}) From 9452844d77ec4303acfddc84d83faf5954f255d1 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 12 Feb 2026 08:44:18 -0800 Subject: [PATCH 02/15] fix(opencode): reset caches and emit hot reload event --- packages/opencode/src/agent/agent.ts | 4 ++ packages/opencode/src/command/index.ts | 4 ++ packages/opencode/src/config/config.ts | 4 ++ packages/opencode/src/project/hotreload.ts | 82 +++++++++++++++++++--- packages/opencode/src/project/instance.ts | 2 +- packages/opencode/src/project/state.ts | 33 ++++++++- packages/opencode/src/skill/skill.ts | 4 ++ 7 files changed, 119 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c53ca04e2383..4dd079dbae38 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -254,6 +254,10 @@ export namespace Agent { return state().then((x) => x[agent]) } + export async function reset() { + await state.reset() + } + export async function list() { const cfg = await Config.get() return pipe( diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dce7ac8bbc34..5c67381d9363 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -144,6 +144,10 @@ export namespace Command { return state().then((x) => x[name]) } + export async function reset() { + await state.reset() + } + export async function list() { return state().then((x) => Object.values(x)) } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 261731b8b0a4..b7658d05a25f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1363,6 +1363,10 @@ export namespace Config { return state().then((x) => x.config) } + export async function reset() { + await state.reset() + } + export async function getGlobal() { return global() } diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index 6ed677382d2b..afedde290135 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -1,13 +1,30 @@ import path from "path" import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import { Config } from "@/config/config" import { FileWatcher } from "@/file/watcher" import { Flag } from "@/flag/flag" +import { SessionStatus } from "@/session/status" +import { Skill } from "@/skill" import { Log } from "@/util/log" import { Instance } from "./instance" +import z from "zod" export namespace HotReload { const log = Log.create({ service: "project.hotreload" }) + export const Event = { + Applied: BusEvent.define( + "opencode.hotreload.applied", + z.object({ + file: z.string(), + event: z.enum(["add", "change", "unlink"]), + }), + ), + } + const watched = new Set([ "agent", "agents", @@ -99,6 +116,7 @@ export namespace HotReload { let timer: ReturnType | undefined let busy = false let last = 0 + let queued = false let latest: | { file: string @@ -106,38 +124,72 @@ export namespace HotReload { } | undefined - const flush = () => { + const active = () => + Object.values(SessionStatus.list()).filter((status) => status.type === "busy" || status.type === "retry").length + + const reload = async () => { + await Config.reset() + await Skill.reset() + await Agent.reset() + await Command.reset() + } + + const flush = (reason: "timer" | "session") => { timer = undefined if (busy) return + const hit = latest + if (!hit) return + + const sessions = active() + if (sessions > 0) { + if (!queued) { + log.info("hot reload queued", { + file: hit.file, + event: hit.event, + sessions, + }) + } + queued = true + return + } + const now = Date.now() const wait = cooldown - (now - last) if (wait > 0) { - timer = setTimeout(flush, wait) + timer = setTimeout(() => flush(reason), wait) return } - const hit = latest - if (!hit) return - busy = true + queued = false + latest = undefined last = now - log.info("hot reload triggered", { file: hit.file, event: hit.event }) - void Instance.dispose() + log.info("hot reload triggered", { file: hit.file, event: hit.event, reason }) + void reload() + .then(() => + Bus.publish(Event.Applied, { + file: hit.file, + event: hit.event, + }), + ) .catch((error) => { log.error("hot reload failed", { error, file: hit.file, event: hit.event }) }) .finally(() => { busy = false + if (!latest) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => flush("timer"), debounce) }) } const schedule = () => { if (timer) clearTimeout(timer) - timer = setTimeout(flush, debounce) + timer = setTimeout(() => flush("timer"), debounce) } - const unsub = Bus.subscribe(FileWatcher.Event.Updated, (event) => { + const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => { const rel = classify(Instance.directory, event.properties.file) if (!rel) return latest = { @@ -147,9 +199,16 @@ export namespace HotReload { schedule() }) + const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => { + if (!queued) return + if (timer) return + timer = setTimeout(() => flush("session"), 0) + }) + log.info("hot reload enabled", { debounce, cooldown }) return { - unsub, + unsubFile, + unsubSession, clear() { if (!timer) return clearTimeout(timer) @@ -158,7 +217,8 @@ export namespace HotReload { } }, async (entry) => { - entry.unsub?.() + entry.unsubFile?.() + entry.unsubSession?.() entry.clear?.() }, ) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f1..4c7ef6f57a1b 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -63,7 +63,7 @@ export const Instance = { if (Instance.worktree === "/") return false return Filesystem.contains(Instance.worktree, filepath) }, - state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { + state(init: () => S, dispose?: (state: Awaited) => Promise): State.Accessor { return State.create(() => Instance.directory, init, dispose) }, async dispose() { diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5eb..8c13978a452a 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -1,6 +1,10 @@ import { Log } from "@/util/log" export namespace State { + export type Accessor = (() => S) & { + reset: () => Promise + } + interface Entry { state: any dispose?: (state: any) => Promise @@ -9,8 +13,8 @@ export namespace State { const log = Log.create({ service: "state" }) const recordsByKey = new Map>() - export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { - return () => { + export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise): Accessor { + const fn = (() => { const key = root() let entries = recordsByKey.get(key) if (!entries) { @@ -25,7 +29,32 @@ export namespace State { dispose, }) return state + }) as Accessor + + fn.reset = async () => { + await disposeInit(root(), init) } + + return fn + } + + async function disposeInit(key: string, init: any) { + const entries = recordsByKey.get(key) + if (!entries) return + const entry = entries.get(init) + if (!entry) return + + if (entry.dispose) { + await Promise.resolve(entry.state) + .then((state) => entry.dispose!(state)) + .catch((error) => { + const label = typeof init === "function" ? init.name : String(init) + log.error("Error while disposing state:", { error, key, init: label }) + }) + } + + entries.delete(init) + if (!entries.size) recordsByKey.delete(key) } export async function dispose(key: string) { diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 42795b7ebcc3..9b37d46fee61 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -178,6 +178,10 @@ export namespace Skill { return state().then((x) => x.skills[name]) } + export async function reset() { + await state.reset() + } + export async function all() { return state().then((x) => Object.values(x.skills)) } From 331d537766779fcc6cb4964747531a7150014a99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:24:53 +0000 Subject: [PATCH 03/15] feat(experimental): add hot reload API trigger # Conflicts: # packages/opencode/src/flag/flag.ts --- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/flag/flag.ts | 8 +-- packages/opencode/src/project/hotreload.ts | 51 ++++++++++++++----- .../src/server/routes/experimental.ts | 48 +++++++++++++++++ 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 7a97f0a49e51..fdbb881f44b5 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -46,7 +46,7 @@ export namespace FileWatcher { const state = Instance.state( async () => { - if (Instance.project.vcs !== "git" && !Flag.OPENCODE_HOT_RELOAD) return {} + if (Instance.project.vcs !== "git" && !Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -75,7 +75,7 @@ export namespace FileWatcher { const subs: ParcelWatcher.AsyncSubscription[] = [] const cfgIgnores = cfg.watcher?.ignore ?? [] - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_HOT_RELOAD) { + if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) { const pending = w.subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], backend, diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f8a3d7df8871..92eb6eb21c52 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -31,12 +31,14 @@ export namespace Flag { export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") - export const OPENCODE_HOT_RELOAD = truthy("OPENCODE_HOT_RELOAD") - export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS") - export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS") // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") + export const OPENCODE_HOT_RELOAD = truthy("OPENCODE_HOT_RELOAD") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = + OPENCODE_EXPERIMENTAL || OPENCODE_HOT_RELOAD || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS") + export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index afedde290135..1024a961c32a 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -109,7 +109,7 @@ export namespace HotReload { const state = Instance.state( () => { - if (!Flag.OPENCODE_HOT_RELOAD) return {} + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {} const debounce = Flag.OPENCODE_HOT_RELOAD_DEBOUNCE_MS ?? 700 const cooldown = Flag.OPENCODE_HOT_RELOAD_COOLDOWN_MS ?? 1500 @@ -134,12 +134,17 @@ export namespace HotReload { await Command.reset() } - const flush = (reason: "timer" | "session") => { + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => flush("timer"), debounce) + } + + const flush = (reason: "timer" | "session" | "api") => { timer = undefined - if (busy) return + if (busy) return { ok: true, queued, sessions: active() } const hit = latest - if (!hit) return + if (!hit) return { ok: true, queued, sessions: active() } const sessions = active() if (sessions > 0) { @@ -151,14 +156,14 @@ export namespace HotReload { }) } queued = true - return + return { ok: true, queued: true, sessions } } const now = Date.now() const wait = cooldown - (now - last) if (wait > 0) { timer = setTimeout(() => flush(reason), wait) - return + return { ok: true, queued: false, sessions, wait } } busy = true @@ -182,21 +187,26 @@ export namespace HotReload { if (timer) clearTimeout(timer) timer = setTimeout(() => flush("timer"), debounce) }) + return { ok: true, queued: false, sessions } } - const schedule = () => { - if (timer) clearTimeout(timer) - timer = setTimeout(() => flush("timer"), debounce) + const request = (hit: { file: string; event: "add" | "change" | "unlink" }, mode: "file" | "api") => { + latest = hit + if (mode === "api") return flush("api") + schedule() + return { ok: true, queued, sessions: active() } } const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => { const rel = classify(Instance.directory, event.properties.file) if (!rel) return - latest = { - file: rel, - event: event.properties.event, - } - schedule() + void request( + { + file: rel, + event: event.properties.event, + }, + "file", + ) }) const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => { @@ -209,6 +219,7 @@ export namespace HotReload { return { unsubFile, unsubSession, + request, clear() { if (!timer) return clearTimeout(timer) @@ -226,4 +237,16 @@ export namespace HotReload { export function init() { state() } + + export function request(input?: { file?: string; event?: "add" | "change" | "unlink" }) { + const entry = state() + const req = "request" in entry ? entry.request : undefined + if (!req) { + return { ok: false, enabled: false } + } + const file = input?.file?.trim() || "api" + const event = input?.event || "change" + const result = req({ file, event }, "api") + return { ...result, enabled: true } + } } diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 3c28331bd529..7fe1f3f08962 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -6,12 +6,60 @@ import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" import { Project } from "../../project/project" import { MCP } from "../../mcp" +import { HotReload } from "../../project/hotreload" +import { Flag } from "../../flag/flag" import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" export const ExperimentalRoutes = lazy(() => new Hono() + .post( + "/hotreload", + describeRoute({ + summary: "Apply hot reload", + description: + "Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.", + operationId: "experimental.hotreload.apply", + responses: { + 200: { + description: "Hot reload scheduled", + content: { + "application/json": { + schema: resolver( + z + .object({ + ok: z.boolean(), + enabled: z.boolean(), + queued: z.boolean().optional(), + sessions: z.number().optional(), + wait: z.number().optional(), + }) + .meta({ ref: "ExperimentalHotReloadResult" }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z + .object({ + file: z.string().optional(), + event: z.enum(["add", "change", "unlink"]).optional(), + }) + .optional(), + ), + async (c) => { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) { + return c.json({ ok: false, enabled: false }, 400) + } + const body = c.req.valid("json") + return c.json(HotReload.request(body ?? undefined)) + }, + ) .get( "/tool/ids", describeRoute({ From e06d443fc370bc9e2e9628ed66eb1769711b0e07 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 09:40:54 -0800 Subject: [PATCH 04/15] chore(sdk): regenerate OpenAPI for hot reload --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 116 +++++++++++++++++------- packages/sdk/js/src/v2/gen/types.gen.ts | 48 ++++++++++ packages/sdk/openapi.json | 109 ++++++++++++++++++++++ 3 files changed, 240 insertions(+), 33 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index af79c44a17a7..381fe797b179 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -24,6 +24,8 @@ import type { EventTuiPromptAppend, EventTuiSessionSelect, EventTuiToastShow, + ExperimentalHotreloadApplyErrors, + ExperimentalHotreloadApplyResponses, ExperimentalResourceListResponses, FileListResponses, FilePartInput, @@ -719,6 +721,82 @@ export class Config2 extends HeyApiClient { } } +export class Hotreload extends HeyApiClient { + /** + * Apply hot reload + * + * Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware. + */ + public apply( + parameters?: { + directory?: string + file?: string + event?: "add" | "change" | "unlink" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "file" }, + { in: "body", key: "event" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalHotreloadApplyResponses, + ExperimentalHotreloadApplyErrors, + ThrowOnError + >({ + url: "/experimental/hotreload", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + +export class Experimental extends HeyApiClient { + private _hotreload?: Hotreload + get hotreload(): Hotreload { + return (this._hotreload ??= new Hotreload({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } +} + export class Tool extends HeyApiClient { /** * List tool IDs @@ -898,34 +976,6 @@ export class Worktree extends HeyApiClient { } } -export class Resource extends HeyApiClient { - /** - * Get MCP resources - * - * Get all available MCP resources from connected servers. Optionally filter by name. - */ - public list( - parameters?: { - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", - ...options, - ...params, - }) - } -} - -export class Experimental extends HeyApiClient { - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } -} - export class Session extends HeyApiClient { /** * List sessions @@ -3216,6 +3266,11 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -3226,11 +3281,6 @@ export class OpencodeClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) - } - private _session?: Session get session(): Session { return (this._session ??= new Session({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index efb7e202e120..e013eafbc938 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -941,6 +941,14 @@ export type EventWorktreeFailed = { } } +export type EventOpencodeHotreloadApplied = { + type: "opencode.hotreload.applied" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -985,6 +993,7 @@ export type Event = | EventPtyDeleted | EventWorktreeReady | EventWorktreeFailed + | EventOpencodeHotreloadApplied export type GlobalEvent = { directory: string @@ -2012,6 +2021,14 @@ export type Provider = { } } +export type ExperimentalHotReloadResult = { + ok: boolean + enabled: boolean + queued?: boolean + sessions?: number + wait?: number +} + export type ToolIds = Array export type ToolListItem = { @@ -2715,6 +2732,37 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ExperimentalHotreloadApplyData = { + body?: { + file?: string + event?: "add" | "change" | "unlink" + } + path?: never + query?: { + directory?: string + } + url: "/experimental/hotreload" +} + +export type ExperimentalHotreloadApplyErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalHotreloadApplyError = ExperimentalHotreloadApplyErrors[keyof ExperimentalHotreloadApplyErrors] + +export type ExperimentalHotreloadApplyResponses = { + /** + * Hot reload scheduled + */ + 200: ExperimentalHotReloadResult +} + +export type ExperimentalHotreloadApplyResponse = + ExperimentalHotreloadApplyResponses[keyof ExperimentalHotreloadApplyResponses] + export type ToolIdsData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 85a1af9d70cc..64d5f61ba07e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -906,6 +906,68 @@ ] } }, + "/experimental/hotreload": { + "post": { + "operationId": "experimental.hotreload.apply", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Apply hot reload", + "description": "Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.", + "responses": { + "200": { + "description": "Hot reload scheduled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExperimentalHotReloadResult" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.hotreload.apply({\n ...\n})" + } + ] + } + }, "/experimental/tool/ids": { "get": { "operationId": "tool.ids", @@ -8420,6 +8482,29 @@ }, "required": ["type", "properties"] }, + "Event.opencode.hotreload.applied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "opencode.hotreload.applied" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event": { "anyOf": [ { @@ -8550,6 +8635,9 @@ }, { "$ref": "#/components/schemas/Event.worktree.failed" + }, + { + "$ref": "#/components/schemas/Event.opencode.hotreload.applied" } ] }, @@ -10429,6 +10517,27 @@ }, "required": ["id", "name", "source", "env", "options", "models"] }, + "ExperimentalHotReloadResult": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "queued": { + "type": "boolean" + }, + "sessions": { + "type": "number" + }, + "wait": { + "type": "number" + } + }, + "required": ["ok", "enabled"] + }, "ToolIDs": { "type": "array", "items": { From d11940cc9e88a79f1237d8f10bc5e6c406c4d3d8 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 09:47:00 -0800 Subject: [PATCH 05/15] test(hotreload): gate darwin path alias on macOS --- packages/opencode/test/project/hotreload.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/test/project/hotreload.test.ts b/packages/opencode/test/project/hotreload.test.ts index 984e352209a8..025bcdbfbf28 100644 --- a/packages/opencode/test/project/hotreload.test.ts +++ b/packages/opencode/test/project/hotreload.test.ts @@ -30,6 +30,7 @@ test("ignores metadata, temp files, and unrelated files", () => { }) test("matches darwin /private path aliases", () => { + if (process.platform !== "darwin") return const privateRoot = "/private/tmp/openwork-hotreload" expect(HotReload.classify(privateRoot, "/tmp/openwork-hotreload/.opencode/commands/fix.md")).toBe( ".opencode/commands/fix.md", From 135a7665da46687d15e3c710cde1b7bc3369c100 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 09:54:12 -0800 Subject: [PATCH 06/15] chore(flags): simplify hot reload gating --- packages/opencode/src/flag/flag.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 92eb6eb21c52..39a655b1a700 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -34,9 +34,8 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") - export const OPENCODE_HOT_RELOAD = truthy("OPENCODE_HOT_RELOAD") export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = - OPENCODE_EXPERIMENTAL || OPENCODE_HOT_RELOAD || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") || truthy("OPENCODE_HOT_RELOAD") export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS") export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") From 8fcbcaac3426ff86db65703554b2853de35de144 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 10:03:07 -0800 Subject: [PATCH 07/15] chore(flags): align hot reload env with experimental --- packages/opencode/src/flag/flag.ts | 7 +++---- packages/opencode/src/project/hotreload.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 39a655b1a700..159b906a6dec 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -34,10 +34,9 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") - export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = - OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") || truthy("OPENCODE_HOT_RELOAD") - export const OPENCODE_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_HOT_RELOAD_DEBOUNCE_MS") - export const OPENCODE_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_HOT_RELOAD_COOLDOWN_MS") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index 1024a961c32a..6ea118099f51 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -111,8 +111,8 @@ export namespace HotReload { () => { if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {} - const debounce = Flag.OPENCODE_HOT_RELOAD_DEBOUNCE_MS ?? 700 - const cooldown = Flag.OPENCODE_HOT_RELOAD_COOLDOWN_MS ?? 1500 + const debounce = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS ?? 700 + const cooldown = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS ?? 1500 let timer: ReturnType | undefined let busy = false let last = 0 From af0d2a8ff21ec268e0a4565ab09cf4d1b2b33a2a Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 10:23:04 -0800 Subject: [PATCH 08/15] feat(hotreload): emit change events and support manual mode --- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/project/hotreload.ts | 26 +++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 159b906a6dec..bca4ccb56605 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -35,6 +35,7 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_MODE = process.env["OPENCODE_EXPERIMENTAL_HOT_RELOAD_MODE"] export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS") export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index 6ea118099f51..36b4c343db36 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -16,6 +16,13 @@ export namespace HotReload { const log = Log.create({ service: "project.hotreload" }) export const Event = { + Changed: BusEvent.define( + "opencode.hotreload.changed", + z.object({ + file: z.string(), + event: z.enum(["add", "change", "unlink"]), + }), + ), Applied: BusEvent.define( "opencode.hotreload.applied", z.object({ @@ -113,6 +120,7 @@ export namespace HotReload { const debounce = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS ?? 700 const cooldown = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS ?? 1500 + const mode = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_MODE === "manual" ? "manual" : "auto" let timer: ReturnType | undefined let busy = false let last = 0 @@ -200,13 +208,15 @@ export namespace HotReload { const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => { const rel = classify(Instance.directory, event.properties.file) if (!rel) return - void request( - { - file: rel, - event: event.properties.event, - }, - "file", - ) + + const hit = { + file: rel, + event: event.properties.event, + } as const + + void Bus.publish(Event.Changed, hit) + if (mode === "manual") return + void request(hit, "file") }) const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => { @@ -215,7 +225,7 @@ export namespace HotReload { timer = setTimeout(() => flush("session"), 0) }) - log.info("hot reload enabled", { debounce, cooldown }) + log.info("hot reload enabled", { debounce, cooldown, mode }) return { unsubFile, unsubSession, From 98488f7b2d3b7954a25097184772d331227ccc00 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 10:30:45 -0800 Subject: [PATCH 09/15] feat(hotreload): make reload userland-driven --- packages/opencode/src/flag/flag.ts | 2 -- packages/opencode/src/project/hotreload.ts | 21 +++++---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index bca4ccb56605..6f50293aa4ae 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -35,8 +35,6 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") - export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_MODE = process.env["OPENCODE_EXPERIMENTAL_HOT_RELOAD_MODE"] - export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS") export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index 36b4c343db36..23acbfdf2f69 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -118,9 +118,7 @@ export namespace HotReload { () => { if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {} - const debounce = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_DEBOUNCE_MS ?? 700 const cooldown = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS ?? 1500 - const mode = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_MODE === "manual" ? "manual" : "auto" let timer: ReturnType | undefined let busy = false let last = 0 @@ -142,11 +140,6 @@ export namespace HotReload { await Command.reset() } - const schedule = () => { - if (timer) clearTimeout(timer) - timer = setTimeout(() => flush("timer"), debounce) - } - const flush = (reason: "timer" | "session" | "api") => { timer = undefined if (busy) return { ok: true, queued, sessions: active() } @@ -193,16 +186,14 @@ export namespace HotReload { busy = false if (!latest) return if (timer) clearTimeout(timer) - timer = setTimeout(() => flush("timer"), debounce) + timer = setTimeout(() => flush("timer"), 0) }) return { ok: true, queued: false, sessions } } - const request = (hit: { file: string; event: "add" | "change" | "unlink" }, mode: "file" | "api") => { + const request = (hit: { file: string; event: "add" | "change" | "unlink" }) => { latest = hit - if (mode === "api") return flush("api") - schedule() - return { ok: true, queued, sessions: active() } + return flush("api") } const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => { @@ -215,8 +206,6 @@ export namespace HotReload { } as const void Bus.publish(Event.Changed, hit) - if (mode === "manual") return - void request(hit, "file") }) const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => { @@ -225,7 +214,7 @@ export namespace HotReload { timer = setTimeout(() => flush("session"), 0) }) - log.info("hot reload enabled", { debounce, cooldown, mode }) + log.info("hot reload enabled", { cooldown, mode: "manual" }) return { unsubFile, unsubSession, @@ -256,7 +245,7 @@ export namespace HotReload { } const file = input?.file?.trim() || "api" const event = input?.event || "change" - const result = req({ file, event }, "api") + const result = req({ file, event }) return { ...result, enabled: true } } } From bd48c5f6f7fe8d9a3b9f7e254fd2041a89632bb3 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 10:30:53 -0800 Subject: [PATCH 10/15] chore(sdk): include hot reload changed event --- packages/sdk/js/src/v2/gen/types.gen.ts | 9 +++++++++ packages/sdk/openapi.json | 26 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e013eafbc938..edd01f4c6884 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -941,6 +941,14 @@ export type EventWorktreeFailed = { } } +export type EventOpencodeHotreloadChanged = { + type: "opencode.hotreload.changed" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventOpencodeHotreloadApplied = { type: "opencode.hotreload.applied" properties: { @@ -993,6 +1001,7 @@ export type Event = | EventPtyDeleted | EventWorktreeReady | EventWorktreeFailed + | EventOpencodeHotreloadChanged | EventOpencodeHotreloadApplied export type GlobalEvent = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 64d5f61ba07e..8c52e0cab865 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8482,6 +8482,29 @@ }, "required": ["type", "properties"] }, + "Event.opencode.hotreload.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "opencode.hotreload.changed" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.opencode.hotreload.applied": { "type": "object", "properties": { @@ -8636,6 +8659,9 @@ { "$ref": "#/components/schemas/Event.worktree.failed" }, + { + "$ref": "#/components/schemas/Event.opencode.hotreload.changed" + }, { "$ref": "#/components/schemas/Event.opencode.hotreload.applied" } From 87d4c2faec7aaf0de3e6a3bf377e5a4de752fd94 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 13:46:06 -0800 Subject: [PATCH 11/15] feat(hotreload): reset plugins, tools, and mcp on apply --- packages/opencode/src/mcp/index.ts | 4 ++++ packages/opencode/src/plugin/index.ts | 4 ++++ packages/opencode/src/project/hotreload.ts | 6 ++++++ packages/opencode/src/tool/registry.ts | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30a..40b44059f664 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -209,6 +209,10 @@ export namespace MCP { }, ) + export async function reset() { + await state.reset() + } + // Helper function to fetch prompts for a specific client async function fetchPromptsForClient(clientName: string, client: Client) { const prompts = await client.listPrompts().catch((e) => { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 24dc695d6350..d27a16c98388 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -119,6 +119,10 @@ export namespace Plugin { return state().then((x) => x.hooks) } + export async function reset() { + await state.reset() + } + export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index 23acbfdf2f69..5631611da6a3 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -6,8 +6,11 @@ import { Command } from "@/command" import { Config } from "@/config/config" import { FileWatcher } from "@/file/watcher" import { Flag } from "@/flag/flag" +import { MCP } from "@/mcp" +import { Plugin } from "@/plugin" import { SessionStatus } from "@/session/status" import { Skill } from "@/skill" +import { ToolRegistry } from "@/tool/registry" import { Log } from "@/util/log" import { Instance } from "./instance" import z from "zod" @@ -135,6 +138,9 @@ export namespace HotReload { const reload = async () => { await Config.reset() + await Plugin.reset() + await MCP.reset() + await ToolRegistry.reset() await Skill.reset() await Agent.reset() await Command.reset() diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3ff9cce8990f..8795ea0968a5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -57,6 +57,10 @@ export namespace ToolRegistry { return { custom } }) + export async function reset() { + await state.reset() + } + function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { id, From 9e1473b67c95221c54266bbd1e9929e3d20c81dc Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 14:00:35 -0800 Subject: [PATCH 12/15] fix(cli): allow --agent with --attach --- packages/opencode/src/cli/cmd/run.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 55cf9a2a0a3e..7e2775bcf15e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -545,9 +545,10 @@ export const RunCommand = cmd({ } } - // Validate agent if specified const agent = await (async () => { if (!args.agent) return undefined + if (args.attach) return args.agent + const entry = await Agent.get(args.agent) if (!entry) { UI.println( From db3c64d0cd465e05b0a42da2bb3bd0f424104c33 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Fri, 13 Feb 2026 22:08:59 -0800 Subject: [PATCH 13/15] Revert "fix(cli): allow --agent with --attach" This reverts commit 7f1c77f3b0c3382a798914716542b95053b6193a. --- packages/opencode/src/cli/cmd/run.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 7e2775bcf15e..55cf9a2a0a3e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -545,10 +545,9 @@ export const RunCommand = cmd({ } } + // Validate agent if specified const agent = await (async () => { if (!args.agent) return undefined - if (args.attach) return args.agent - const entry = await Agent.get(args.agent) if (!entry) { UI.println( From 57db4b703c064d51885be73fc3cd4e4fbb073e15 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 14 Feb 2026 10:48:05 -0800 Subject: [PATCH 14/15] fix(hotreload): apply within instance context --- packages/opencode/src/project/hotreload.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts index 5631611da6a3..c198a8b8e1ec 100644 --- a/packages/opencode/src/project/hotreload.ts +++ b/packages/opencode/src/project/hotreload.ts @@ -177,16 +177,20 @@ export namespace HotReload { queued = false latest = undefined last = now - log.info("hot reload triggered", { file: hit.file, event: hit.event, reason }) - void reload() - .then(() => - Bus.publish(Event.Applied, { + const directory = Instance.directory + log.info("hot reload triggered", { directory, file: hit.file, event: hit.event, reason }) + void Instance.provide({ + directory, + async fn() { + await reload() + await Bus.publish(Event.Applied, { file: hit.file, event: hit.event, - }), - ) + }) + }, + }) .catch((error) => { - log.error("hot reload failed", { error, file: hit.file, event: hit.event }) + log.error("hot reload failed", { error, directory, file: hit.file, event: hit.event }) }) .finally(() => { busy = false From 25fe6ba4bc274a3596b09dc23d8b0c35ecce1220 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:26:12 +0000 Subject: [PATCH 15/15] chore(sync): feat/hot-reload-smooth onto upstream/dev (#8) * feat(opencode): add `cljfmt` formatter support for Clojure files (#13426) * fix(website): correct zh-CN translation of proprietary terms in zen.mdx (#13734) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * chore: generate * desktop: use process-wrap instead of manual job object (#13431) * feat(opencode): Add Venice support in temperature, topP, topK and smallOption (#13553) * feat(opencode): add conservative workspace hot reload * fix(opencode): reset caches and emit hot reload event * feat(experimental): add hot reload API trigger * chore(sdk): regenerate OpenAPI for hot reload * test(hotreload): gate darwin path alias on macOS * chore(flags): simplify hot reload gating * chore(flags): align hot reload env with experimental * feat(hotreload): emit change events and support manual mode * feat(hotreload): make reload userland-driven * chore(sdk): include hot reload changed event * feat(hotreload): reset plugins, tools, and mcp on apply * fix(cli): allow --agent with --attach * Revert "fix(cli): allow --agent with --attach" This reverts commit 7f1c77f3b0c3382a798914716542b95053b6193a. * fix(hotreload): apply within instance context --------- Co-authored-by: Salam Elbilig Co-authored-by: Pan Kaixin Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: Brendan Allan Co-authored-by: dpuyosa Co-authored-by: Benjamin Shafii # Conflicts: # packages/desktop/src-tauri/src/cli.rs # packages/opencode/src/format/formatter.ts # packages/web/src/content/docs/formatters.mdx # packages/web/src/content/docs/zh-cn/zen.mdx --- packages/desktop/src-tauri/src/cli.rs | 2 +- packages/web/src/content/docs/zh-cn/zen.mdx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index cb6d3ad3729b..28a7c58f55be 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -391,9 +391,9 @@ pub fn spawn_command( let _ = tx.send(CommandEvent::Error(err.to_string())).await; } } - stdout.abort(); stderr.abort(); + }); let event_stream = ReceiverStream::new(rx); diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 39358c417007..12c3a81f8e58 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -201,7 +201,11 @@ Zen 也非常适合团队使用。你可以邀请队友、分配角色、管理 你可以邀请团队成员加入你的工作区并分配角色: -- **管理员**:管理模型、成员、API 密钥和账单 +<<<<<<< HEAD + +- # **管理员**:管理模型、成员、API 密钥和账单 +- **管理员**:管理模型、成员、API 密钥和计费/账单 + > > > > > > > 2b53ac320 (chore(sync): feat/hot-reload-smooth onto upstream/dev (#8)) - **成员**:仅管理自己的 API 密钥 管理员还可以为每个成员设置月度支出限额,以控制成本。